whalibmob 5.11.0 → 5.12.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/.env.example +9 -0
- package/README.md +46 -0
- package/cli.js +45 -2
- package/lib/Registration.js +465 -49
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -80,6 +80,15 @@
|
|
|
80
80
|
# WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
# ─── Registration funnel telemetry ───────────────────────────────────────────
|
|
84
|
+
# Registration reports the screens it passes through to WhatsApp's /client_log,
|
|
85
|
+
# the way the phone clients do. It is fire-and-forget — every failure is
|
|
86
|
+
# swallowed and it can never take a registration down.
|
|
87
|
+
#
|
|
88
|
+
# Set to 0 to send none of it.
|
|
89
|
+
# WA_FUNNEL_LOG=0
|
|
90
|
+
|
|
91
|
+
|
|
83
92
|
# ─── Frida attestation bridge ────────────────────────────────────────────────
|
|
84
93
|
# Host running the Frida attestation helper (see the frida/ directory).
|
|
85
94
|
# Leave unset to disable the bridge.
|
package/README.md
CHANGED
|
@@ -154,6 +154,7 @@ npm install -g whalibmob
|
|
|
154
154
|
- [Knowing Which Mode You Are In](#knowing-which-mode-you-are-in)
|
|
155
155
|
- [Two Sessions on One Number](#two-sessions-on-one-number)
|
|
156
156
|
- [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under)
|
|
157
|
+
- [When Registration Is Refused for Consent](#when-registration-is-refused-for-consent)
|
|
157
158
|
- [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
|
|
158
159
|
- [Saving & Restoring Sessions](#saving--restoring-sessions)
|
|
159
160
|
- [Signal Store Utilities](#signal-store-utilities)
|
|
@@ -306,6 +307,30 @@ if (result.status === 'ok') {
|
|
|
306
307
|
}
|
|
307
308
|
```
|
|
308
309
|
|
|
310
|
+
**When the code is not the end of it.** The server can answer a submitted code with a CAPTCHA, or with a demand for the account's two-step verification PIN. Neither is something the library can work out on its own, so both come back to you through optional handlers:
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
const result = await verifyCode(store, '123456', {
|
|
314
|
+
// image and audio are Buffers, or null when that variant was not sent.
|
|
315
|
+
// Return the answer, or null to give up.
|
|
316
|
+
async solveCaptcha({ image, audio }) {
|
|
317
|
+
fs.writeFileSync('captcha.png', image)
|
|
318
|
+
return await askTheUser()
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
// The six-digit PIN set on the phone under
|
|
322
|
+
// Settings → Account → Two-step verification.
|
|
323
|
+
async twoFactorPin() {
|
|
324
|
+
return await askTheUser()
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Both are optional and both keep working when omitted — you get an error naming what was asked for instead of a silent failure, with the CAPTCHA blobs attached as `err.captcha`. A wrong CAPTCHA answer is replied to with another one, so `solveCaptcha` may be called several times. The CLI prompts for both, writing the image to a temp file first.
|
|
330
|
+
|
|
331
|
+
> [!NOTE]
|
|
332
|
+
> Registration reports the screens it passes through to WhatsApp's `/client_log`, the way the phone clients do — a client that registers in total silence does something no real installation does. It is fire-and-forget and every failure is swallowed, so it can never take a registration down. Set `WA_FUNNEL_LOG=0` to send none of it.
|
|
333
|
+
|
|
309
334
|
### Device Attestation with Frida (optional)
|
|
310
335
|
|
|
311
336
|
WhatsApp's registration servers score every `/code` and `/register` request on how
|
|
@@ -863,6 +888,27 @@ if (probe.mismatch) {
|
|
|
863
888
|
> [!NOTE]
|
|
864
889
|
> The check costs nothing when things work — it runs only after a login has already been refused.
|
|
865
890
|
|
|
891
|
+
## When Registration Is Refused for Consent
|
|
892
|
+
|
|
893
|
+
Some numbers come back from `/register` like this:
|
|
894
|
+
|
|
895
|
+
```json
|
|
896
|
+
{ "login": "557176034186", "pending": "app_store_age", "reason": "consent", "status": "fail" }
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
The code was not refused and the account was found — WhatsApp is asking for an age signal that only a real app-store install carries, and will not finish without it. Brazilian numbers are where this turns up in practice.
|
|
900
|
+
|
|
901
|
+
The first thing to try is the Android device profile. The iOS registration request carries six fields and none of them says anything about consent, terms or age; the Android one carries `tos_version`, `education_screen_displayed` and `clicked_education_link`.
|
|
902
|
+
|
|
903
|
+
```sh
|
|
904
|
+
WA_OS=android wa registration --code 5571976034186
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
If that is refused too, the number has to go through the real app once, on a phone, before it can be registered here.
|
|
908
|
+
|
|
909
|
+
> [!NOTE]
|
|
910
|
+
> The `login` field in that reply is worth reading. Brazilian mobiles gained a ninth digit that WhatsApp never adopted, so `+5571976034186` is filed as `+557176034186`. whalibmob adopts the server's form automatically on a successful registration and saves the session under it — the digit difference is not itself the failure.
|
|
911
|
+
|
|
866
912
|
## Routing Registration Through a Proxy
|
|
867
913
|
|
|
868
914
|
Registration is the part of the protocol most likely to be refused from a datacenter IP. If you are seeing security blocks or an undeterminable number status, route it through a SOCKS proxy — Tor, or a residential provider.
|
package/cli.js
CHANGED
|
@@ -833,6 +833,49 @@ function askLoginMethod(phone) {
|
|
|
833
833
|
});
|
|
834
834
|
}
|
|
835
835
|
|
|
836
|
+
// Handlers for the two things a registration can stop and ask for.
|
|
837
|
+
//
|
|
838
|
+
// The server can answer a submitted code with a captcha, or with a demand for
|
|
839
|
+
// the account's two-step PIN. Neither is something the library can work out, so
|
|
840
|
+
// both come back to whoever is driving it — here, the person at the terminal.
|
|
841
|
+
function registrationPrompts() {
|
|
842
|
+
const prompt = (question) => new Promise((resolve) => {
|
|
843
|
+
if (!process.stdin.isTTY) return resolve(null);
|
|
844
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
845
|
+
rl.question(question, (answer) => { rl.close(); resolve(String(answer).trim()); });
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
async solveCaptcha({ image, audio }) {
|
|
850
|
+
out('');
|
|
851
|
+
hr();
|
|
852
|
+
out(' WhatsApp is asking for a CAPTCHA before it will finish this registration.');
|
|
853
|
+
// Written out rather than described: it is a picture, and there is
|
|
854
|
+
// nothing useful to say about it in a terminal.
|
|
855
|
+
for (const [what, buf, ext] of [['image', image, 'png'], ['audio', audio, 'mp3']]) {
|
|
856
|
+
if (!buf || !buf.length) continue;
|
|
857
|
+
const file = path.join(os.tmpdir(), 'whalibmob-captcha-' + Date.now() + '.' + ext);
|
|
858
|
+
try {
|
|
859
|
+
fs.writeFileSync(file, buf);
|
|
860
|
+
out(' ' + what + ' saved to ' + file + ' (' + buf.length + ' bytes)');
|
|
861
|
+
} catch (e) {
|
|
862
|
+
out(' could not save the ' + what + ': ' + e.message);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
hr();
|
|
866
|
+
const answer = await prompt(' what does it say? ');
|
|
867
|
+
return answer || null;
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
async twoFactorPin() {
|
|
871
|
+
out('');
|
|
872
|
+
out(' this number has two-step verification switched on');
|
|
873
|
+
const pin = await prompt(' six-digit PIN: ');
|
|
874
|
+
return pin || null;
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
836
879
|
// Link as a companion device.
|
|
837
880
|
//
|
|
838
881
|
// Connect first, then ask for the code: the request rides on the encrypted
|
|
@@ -2021,7 +2064,7 @@ async function handleLine(line) {
|
|
|
2021
2064
|
const file = path.join(_sessDir, `${ph}.json`);
|
|
2022
2065
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2023
2066
|
out('verifying...');
|
|
2024
|
-
const r = await verifyCode(store, code);
|
|
2067
|
+
const r = await verifyCode(store, code, registrationPrompts());
|
|
2025
2068
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2026
2069
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2027
2070
|
const finalStore = r.store || store;
|
|
@@ -2419,7 +2462,7 @@ async function main() {
|
|
|
2419
2462
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2420
2463
|
out('verifying code for +' + ph + '...');
|
|
2421
2464
|
try {
|
|
2422
|
-
const r = await verifyCode(store, code);
|
|
2465
|
+
const r = await verifyCode(store, code, registrationPrompts());
|
|
2423
2466
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2424
2467
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2425
2468
|
const finalStore = r.store || store;
|
package/lib/Registration.js
CHANGED
|
@@ -6,6 +6,7 @@ const tls = require('tls');
|
|
|
6
6
|
const curveJs = require('curve25519-js');
|
|
7
7
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
8
8
|
const attestation = require('./Attestation');
|
|
9
|
+
const { dbg: _whaDbg } = require('./logger');
|
|
9
10
|
|
|
10
11
|
// ---------- SOCKS4 / SOCKS5 / Tor support ----------
|
|
11
12
|
//
|
|
@@ -136,51 +137,144 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
|
136
137
|
`Content-Length: ${Buffer.byteLength(body)}`
|
|
137
138
|
];
|
|
138
139
|
if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
140
|
+
// Asked for, but not relied on: the server answers and then keeps the
|
|
141
|
+
// connection open anyway. The response is finished by its own length, not by
|
|
142
|
+
// the socket closing.
|
|
142
143
|
reqLines.push(`Connection: close`, '', body);
|
|
143
144
|
const req = reqLines.join('\r\n');
|
|
144
145
|
|
|
145
146
|
tlsSocket.write(req);
|
|
146
147
|
|
|
147
|
-
const
|
|
148
|
-
|
|
148
|
+
const { status, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
|
|
149
|
+
const bodyStr = bodyBuf.toString('utf8');
|
|
150
|
+
|
|
151
|
+
if (status !== 200) throw new Error(`HTTP ${status} ${path}: ${bodyStr}`);
|
|
152
|
+
try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A response is over when its own length says so, not when the socket shuts.
|
|
156
|
+
//
|
|
157
|
+
// This is the part `https.request` does for you and a hand-rolled socket does
|
|
158
|
+
// not. WhatsApp answers a registration request in well under a second and then
|
|
159
|
+
// leaves the connection open — `Connection: close` in the request does not
|
|
160
|
+
// change that. Waiting for end or close therefore waited for something that
|
|
161
|
+
// never came, and every proxied registration died on the read timeout with the
|
|
162
|
+
// reply already sitting in the buffer.
|
|
163
|
+
//
|
|
164
|
+
// So: parse as the bytes arrive, and stop at the end of the body — the byte
|
|
165
|
+
// count in Content-Length, or the terminating zero-length chunk. end and close
|
|
166
|
+
// stay as a fallback, for a reply that declares neither and can only be
|
|
167
|
+
// delimited by the connection itself.
|
|
168
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
169
|
+
|
|
170
|
+
function readHttpResponse(socket, label, timeoutMs) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
let raw = Buffer.alloc(0);
|
|
173
|
+
let done = false;
|
|
174
|
+
|
|
149
175
|
const timer = setTimeout(() => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
176
|
+
settle(reject, new Error('SOCKS proxy timed out reading ' + label));
|
|
177
|
+
}, timeoutMs || 30000);
|
|
178
|
+
|
|
179
|
+
function settle(fn, arg) {
|
|
180
|
+
if (done) return;
|
|
181
|
+
done = true;
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
socket.removeListener('data', onData);
|
|
184
|
+
socket.removeListener('end', onEnd);
|
|
185
|
+
socket.removeListener('close', onEnd);
|
|
186
|
+
socket.removeListener('error', onError);
|
|
187
|
+
socket.destroy();
|
|
188
|
+
fn(arg);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function onData(chunk) {
|
|
192
|
+
raw = raw.length ? Buffer.concat([raw, chunk]) : chunk;
|
|
193
|
+
if (raw.length > MAX_RESPONSE_BYTES) {
|
|
194
|
+
return settle(reject, new Error('response too large reading ' + label));
|
|
195
|
+
}
|
|
196
|
+
const parsed = parseHttpResponse(raw, false);
|
|
197
|
+
if (parsed) settle(resolve, parsed);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The connection did close after all — then whatever arrived is the whole
|
|
201
|
+
// reply, even without a declared length.
|
|
202
|
+
function onEnd() {
|
|
203
|
+
const parsed = parseHttpResponse(raw, true);
|
|
204
|
+
if (parsed) return settle(resolve, parsed);
|
|
205
|
+
settle(reject, new Error('connection closed mid-response reading ' + label));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function onError(err) { settle(reject, err); }
|
|
209
|
+
|
|
210
|
+
socket.on('data', onData);
|
|
211
|
+
socket.on('end', onEnd);
|
|
212
|
+
socket.on('close', onEnd);
|
|
213
|
+
socket.on('error', onError);
|
|
158
214
|
});
|
|
215
|
+
}
|
|
159
216
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Parse what has arrived so far.
|
|
219
|
+
*
|
|
220
|
+
* Returns { status, headers, body } once the response is complete, or null
|
|
221
|
+
* while it is still short. With `atEof` the caller is telling us no more bytes
|
|
222
|
+
* are coming, which is itself a valid way for a body to end.
|
|
223
|
+
*/
|
|
224
|
+
function parseHttpResponse(raw, atEof) {
|
|
225
|
+
const headerEnd = raw.indexOf('\r\n\r\n');
|
|
226
|
+
if (headerEnd < 0) return null;
|
|
227
|
+
|
|
228
|
+
// Headers are latin1 by definition; only the body may be UTF-8.
|
|
229
|
+
const headers = raw.slice(0, headerEnd).toString('latin1');
|
|
230
|
+
const status = parseInt(headers.split(' ')[1], 10);
|
|
231
|
+
const bodyStart = headerEnd + 4;
|
|
232
|
+
|
|
233
|
+
if (/^transfer-encoding:[ \t]*chunked/im.test(headers)) {
|
|
234
|
+
const decoded = decodeChunkedBody(raw.slice(bodyStart));
|
|
235
|
+
if (decoded) return { status, headers, body: decoded };
|
|
236
|
+
return atEof ? { status, headers, body: Buffer.alloc(0) } : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const match = /^content-length:[ \t]*(\d+)/im.exec(headers);
|
|
240
|
+
if (match) {
|
|
241
|
+
const length = parseInt(match[1], 10);
|
|
242
|
+
// Counted in bytes, which is why this works on the buffer and not on a
|
|
243
|
+
// decoded string — one accented character in an error message is two bytes
|
|
244
|
+
// and would leave the check one short forever.
|
|
245
|
+
if (raw.length - bodyStart >= length) {
|
|
246
|
+
return { status, headers, body: raw.slice(bodyStart, bodyStart + length) };
|
|
178
247
|
}
|
|
179
|
-
|
|
248
|
+
return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
|
|
180
249
|
}
|
|
181
250
|
|
|
182
|
-
|
|
183
|
-
|
|
251
|
+
// These two carry no body at all, whatever else the headers say.
|
|
252
|
+
if (status === 204 || status === 304) {
|
|
253
|
+
return { status, headers, body: Buffer.alloc(0) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// No length anywhere: the body runs to the end of the connection.
|
|
257
|
+
return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Decode a chunked body, or null while the terminating chunk is still missing. */
|
|
261
|
+
function decodeChunkedBody(buf) {
|
|
262
|
+
const parts = [];
|
|
263
|
+
let pos = 0;
|
|
264
|
+
|
|
265
|
+
for (;;) {
|
|
266
|
+
const lineEnd = buf.indexOf('\r\n', pos);
|
|
267
|
+
if (lineEnd < 0) return null;
|
|
268
|
+
// A size line may carry extensions after a semicolon; parseInt stops there.
|
|
269
|
+
const size = parseInt(buf.slice(pos, lineEnd).toString('latin1'), 16);
|
|
270
|
+
if (!Number.isFinite(size) || size < 0) return null;
|
|
271
|
+
if (size === 0) return Buffer.concat(parts); // the terminating chunk
|
|
272
|
+
const start = lineEnd + 2;
|
|
273
|
+
const end = start + size;
|
|
274
|
+
if (buf.length < end + 2) return null; // chunk still arriving
|
|
275
|
+
parts.push(buf.slice(start, end));
|
|
276
|
+
pos = end + 2;
|
|
277
|
+
}
|
|
184
278
|
}
|
|
185
279
|
const {
|
|
186
280
|
REGISTRATION_ENDPOINT,
|
|
@@ -651,6 +745,18 @@ function httpPost(path, body, waVersion, authHeader) {
|
|
|
651
745
|
|
|
652
746
|
async function sendRequest(path, store, waVersion, useToken, extraPairs) {
|
|
653
747
|
const plaintext = await buildPayload(store, waVersion, useToken, extraPairs);
|
|
748
|
+
return sendEncrypted(path, plaintext, store, waVersion);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Encrypt an already-built form body and post it.
|
|
753
|
+
*
|
|
754
|
+
* The attested endpoints reach this through sendRequest, which assembles the
|
|
755
|
+
* shared registration fields first. The funnel endpoints build their own much
|
|
756
|
+
* shorter body and come straight here — the native client sends no registration
|
|
757
|
+
* fields with a client log, only the identifiers and the screen names.
|
|
758
|
+
*/
|
|
759
|
+
async function sendEncrypted(path, plaintext, store, waVersion) {
|
|
654
760
|
const enc = encryptPayload(plaintext); // base64url ENC payload (no prefix)
|
|
655
761
|
let body = 'ENC=' + enc;
|
|
656
762
|
|
|
@@ -667,6 +773,243 @@ async function sendRequest(path, store, waVersion, useToken, extraPairs) {
|
|
|
667
773
|
return httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
|
|
668
774
|
}
|
|
669
775
|
|
|
776
|
+
// ---------- Funnel telemetry ----------
|
|
777
|
+
//
|
|
778
|
+
// The native client reports every screen it moves through — the number entry,
|
|
779
|
+
// the code request, the code submission, a captcha appearing, a PIN prompt —
|
|
780
|
+
// to /client_log, and one session-start event to /pre_pn_client_log before the
|
|
781
|
+
// number is even known. A client that registers without any of that is doing
|
|
782
|
+
// something no real installation does.
|
|
783
|
+
//
|
|
784
|
+
// It is fire-and-forget by design. Every failure is swallowed: telemetry that
|
|
785
|
+
// can abort a registration is worse than no telemetry. Set WA_FUNNEL_LOG=0 to
|
|
786
|
+
// send none of it.
|
|
787
|
+
|
|
788
|
+
function funnelEnabled() {
|
|
789
|
+
return process.env.WA_FUNNEL_LOG !== '0';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The screen a verification event belongs to, named after the method that was
|
|
793
|
+
// asked for — verify_sms, verify_voice. Before any request there is only the
|
|
794
|
+
// number entry screen.
|
|
795
|
+
function currentVerifyScreen(store) {
|
|
796
|
+
return store._lastRequestedMethod ? 'verify_' + store._lastRequestedMethod : 'enter_number';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function sendFunnelLog(store, waVersion, currentScreen, actionTaken, eventName) {
|
|
800
|
+
if (!funnelEnabled()) return;
|
|
801
|
+
try {
|
|
802
|
+
const { cc, national } = parsePhone(store.phoneNumber);
|
|
803
|
+
const device = store.device || getDeviceConfig();
|
|
804
|
+
const fdid = device.os === 'android'
|
|
805
|
+
? store.fdid.toLowerCase()
|
|
806
|
+
: store.fdid.toUpperCase();
|
|
807
|
+
|
|
808
|
+
const body = buildForm([
|
|
809
|
+
'cc', cc,
|
|
810
|
+
'in', national,
|
|
811
|
+
'lg', 'en',
|
|
812
|
+
'lc', 'US',
|
|
813
|
+
'expid', toBase64Url(store.deviceId),
|
|
814
|
+
'fdid', fdid,
|
|
815
|
+
'id', toUrlHex(store.identityId),
|
|
816
|
+
'current_screen', currentScreen,
|
|
817
|
+
'previous_screen', store._funnelPreviousScreen || '',
|
|
818
|
+
'action_taken', actionTaken,
|
|
819
|
+
'event_name', eventName
|
|
820
|
+
], null);
|
|
821
|
+
|
|
822
|
+
await sendEncrypted('/client_log', body, store, waVersion);
|
|
823
|
+
store._funnelPreviousScreen = currentScreen;
|
|
824
|
+
} catch (err) {
|
|
825
|
+
_whaDbg('[DBG] REG funnel log failed (' + currentScreen + '/' + actionTaken + '): ' +
|
|
826
|
+
(err && err.message));
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The one event that fires before a phone number is committed, so it carries
|
|
831
|
+
// neither cc nor in.
|
|
832
|
+
async function sendPrePnFunnelLog(store, waVersion, actionTaken, eventName) {
|
|
833
|
+
if (!funnelEnabled()) return;
|
|
834
|
+
try {
|
|
835
|
+
const device = store.device || getDeviceConfig();
|
|
836
|
+
const fdid = device.os === 'android'
|
|
837
|
+
? store.fdid.toLowerCase()
|
|
838
|
+
: store.fdid.toUpperCase();
|
|
839
|
+
|
|
840
|
+
const body = buildForm([
|
|
841
|
+
'lg', 'en',
|
|
842
|
+
'lc', 'US',
|
|
843
|
+
'expid', toBase64Url(store.deviceId),
|
|
844
|
+
'fdid', fdid,
|
|
845
|
+
'id', toUrlHex(store.identityId),
|
|
846
|
+
'current_screen', 'enter_number',
|
|
847
|
+
'previous_screen', '',
|
|
848
|
+
'action_taken', actionTaken,
|
|
849
|
+
'event_name', eventName
|
|
850
|
+
], null);
|
|
851
|
+
|
|
852
|
+
await sendEncrypted('/pre_pn_client_log', body, store, waVersion);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
_whaDbg('[DBG] REG pre-pn funnel log failed: ' + (err && err.message));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------- Challenge and two-factor ----------
|
|
859
|
+
|
|
860
|
+
/** A captcha is a reply carrying an image or an audio blob to be solved. */
|
|
861
|
+
function hasChallenge(response) {
|
|
862
|
+
if (!response) return false;
|
|
863
|
+
return !!((response.image_blob && String(response.image_blob).length) ||
|
|
864
|
+
(response.audio_blob && String(response.audio_blob).length));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Three spellings of the same demand, all of them still in use. */
|
|
868
|
+
function is2FARequired(response) {
|
|
869
|
+
const reason = response && response.reason;
|
|
870
|
+
if (!reason) return false;
|
|
871
|
+
const r = String(reason).toLowerCase();
|
|
872
|
+
return r === '2fa_required' || r === 'security_code' || r === 'two_factor_required';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Decode a blob defensively.
|
|
877
|
+
*
|
|
878
|
+
* The server has been seen to switch between the standard and URL-safe base64
|
|
879
|
+
* alphabets between releases, so both are tried before giving up.
|
|
880
|
+
*/
|
|
881
|
+
function decodeOrNull(b64) {
|
|
882
|
+
if (!b64) return null;
|
|
883
|
+
const str = String(b64);
|
|
884
|
+
if (!str.length) return null;
|
|
885
|
+
for (const alphabet of ['base64', 'base64url']) {
|
|
886
|
+
try {
|
|
887
|
+
const out = Buffer.from(str, alphabet);
|
|
888
|
+
if (out.length) return out;
|
|
889
|
+
} catch (_) {}
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function isSuccessful(status) {
|
|
895
|
+
return status === 'ok' || status === 'sent' || status === 'verified';
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function normalizeCodeResult(code) {
|
|
899
|
+
return String(code == null ? '' : code).replace(/[\s\-]/g, '').replace(/\D/g, '');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Take the digits the server filed the account under. Brazilian mobiles are the
|
|
903
|
+
// standing example: they gained a ninth digit WhatsApp never adopted, and a
|
|
904
|
+
// session saved under the typed number sends a username on every connection
|
|
905
|
+
// that matches no registration.
|
|
906
|
+
function adoptCanonicalNumber(store, result) {
|
|
907
|
+
const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
|
|
908
|
+
if (canonical && canonical !== String(store.phoneNumber)) {
|
|
909
|
+
result.canonicalPhoneNumber = canonical;
|
|
910
|
+
result.typedPhoneNumber = String(store.phoneNumber);
|
|
911
|
+
store.phoneNumber = canonical;
|
|
912
|
+
}
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Answer a captcha, and keep answering while the server keeps asking.
|
|
918
|
+
*
|
|
919
|
+
* A wrong answer is replied to with another challenge rather than a refusal,
|
|
920
|
+
* which is why this loops. Solving needs a caller that can show the image to
|
|
921
|
+
* somebody: without one there is nothing to do but say so, and hand the blobs
|
|
922
|
+
* over on the error so it can be dealt with elsewhere.
|
|
923
|
+
*/
|
|
924
|
+
async function handleChallenge(store, waVersion, initial, opts) {
|
|
925
|
+
const screen = currentVerifyScreen(store);
|
|
926
|
+
await sendFunnelLog(store, waVersion, screen, 'challenge_shown', 'captcha_shown');
|
|
927
|
+
|
|
928
|
+
let response = initial;
|
|
929
|
+
for (;;) {
|
|
930
|
+
const image = decodeOrNull(response.image_blob);
|
|
931
|
+
const audio = decodeOrNull(response.audio_blob);
|
|
932
|
+
|
|
933
|
+
let answer = null;
|
|
934
|
+
if (typeof opts.solveCaptcha === 'function') {
|
|
935
|
+
answer = await opts.solveCaptcha({ image, audio, response });
|
|
936
|
+
}
|
|
937
|
+
if (!answer) {
|
|
938
|
+
await sendFunnelLog(store, waVersion, screen, 'challenge_abandoned', 'captcha_abandoned');
|
|
939
|
+
const err = new Error(
|
|
940
|
+
'Registration needs a CAPTCHA solved' +
|
|
941
|
+
(typeof opts.solveCaptcha === 'function'
|
|
942
|
+
? ', and the solveCaptcha handler returned nothing.'
|
|
943
|
+
: ', and no solveCaptcha handler was given.\n' +
|
|
944
|
+
' Pass one to verifyCode: verifyCode(store, code, { solveCaptcha: async ({ image, audio }) => "…" }).\n' +
|
|
945
|
+
' image and audio are Buffers, or null when that variant was not sent.')
|
|
946
|
+
);
|
|
947
|
+
err.captcha = { image, audio };
|
|
948
|
+
err.raw = response;
|
|
949
|
+
throw err;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
await sendFunnelLog(store, waVersion, screen, 'challenge_submitted', 'captcha_submitted');
|
|
953
|
+
response = await sendRequest('/challenge', store, waVersion, true,
|
|
954
|
+
['code', normalizeCodeResult(answer)]);
|
|
955
|
+
|
|
956
|
+
if (isSuccessful(response.status)) {
|
|
957
|
+
await sendFunnelLog(store, waVersion, 'account_verification_complete',
|
|
958
|
+
'challenge_submitted', 'captcha_success');
|
|
959
|
+
return adoptCanonicalNumber(store, response);
|
|
960
|
+
}
|
|
961
|
+
if (hasChallenge(response)) {
|
|
962
|
+
await sendFunnelLog(store, waVersion, screen, 'challenge_retry', 'captcha_retry');
|
|
963
|
+
continue; // wrong answer, another one
|
|
964
|
+
}
|
|
965
|
+
if (is2FARequired(response)) return handle2FA(store, waVersion, opts);
|
|
966
|
+
|
|
967
|
+
const err = new Error('Registration CAPTCHA refused: ' +
|
|
968
|
+
(response.reason || response.status || JSON.stringify(response)));
|
|
969
|
+
err.raw = response;
|
|
970
|
+
throw err;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Supply the two-step verification PIN the account has set.
|
|
976
|
+
*
|
|
977
|
+
* This is the account owner's own PIN, not anything the library can work out —
|
|
978
|
+
* without a handler to ask, the registration stops here.
|
|
979
|
+
*/
|
|
980
|
+
async function handle2FA(store, waVersion, opts) {
|
|
981
|
+
await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_shown', 'twofac_prompt_shown');
|
|
982
|
+
|
|
983
|
+
let pin = null;
|
|
984
|
+
if (typeof opts.twoFactorPin === 'function') pin = await opts.twoFactorPin();
|
|
985
|
+
if (!pin) {
|
|
986
|
+
await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_abandoned', 'twofac_abandoned');
|
|
987
|
+
throw new Error(
|
|
988
|
+
'This number has two-step verification switched on and the registration ' +
|
|
989
|
+
'needs its PIN' +
|
|
990
|
+
(typeof opts.twoFactorPin === 'function'
|
|
991
|
+
? ', but the twoFactorPin handler returned nothing.'
|
|
992
|
+
: '.\n Pass one to verifyCode: verifyCode(store, code, { twoFactorPin: async () => "123456" }).\n' +
|
|
993
|
+
' It is the six-digit PIN set on the phone under Settings → Account → Two-step verification.')
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_submitted', 'twofac_submitted');
|
|
998
|
+
const response = await sendRequest('/security', store, waVersion, true,
|
|
999
|
+
['code', normalizeCodeResult(pin)]);
|
|
1000
|
+
|
|
1001
|
+
if (isSuccessful(response.status)) {
|
|
1002
|
+
await sendFunnelLog(store, waVersion, 'account_verification_complete',
|
|
1003
|
+
'twofac_submitted', 'twofac_success');
|
|
1004
|
+
return adoptCanonicalNumber(store, response);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const err = new Error('Two-step verification PIN refused: ' +
|
|
1008
|
+
(response.reason || response.status || JSON.stringify(response)));
|
|
1009
|
+
err.raw = response;
|
|
1010
|
+
throw err;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
670
1013
|
// ---------- Public API ----------
|
|
671
1014
|
|
|
672
1015
|
async function checkIfRegistered(store) {
|
|
@@ -778,18 +1121,30 @@ async function checkNumberStatus(phoneNumber) {
|
|
|
778
1121
|
// Returns true → keys are fresh (safe to proceed with /code).
|
|
779
1122
|
// Returns false → keys already registered (generate new store before /code).
|
|
780
1123
|
async function assertRegistrationKeys(store, waVersion) {
|
|
1124
|
+
// The session-start event the native client fires before it knows the number.
|
|
1125
|
+
await sendPrePnFunnelLog(store, waVersion, 'session_start', 'registration_session_start');
|
|
1126
|
+
await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_attempt');
|
|
1127
|
+
|
|
781
1128
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
782
1129
|
try {
|
|
783
1130
|
const result = await sendRequest('/exist', store, waVersion, false, null);
|
|
784
1131
|
// reason === 'incorrect' → keys not found → fresh
|
|
785
|
-
if (result && result.reason === 'incorrect')
|
|
1132
|
+
if (result && result.reason === 'incorrect') {
|
|
1133
|
+
await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_success');
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
786
1136
|
// Any non-ok status on 2nd attempt → still treat as fresh (network/server glitch)
|
|
787
|
-
if (attempt === 1 && result && result.status && result.status !== 'ok')
|
|
1137
|
+
if (attempt === 1 && result && result.status && result.status !== 'ok') {
|
|
1138
|
+
await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_success');
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
788
1141
|
} catch (_) {
|
|
789
1142
|
// Network error = treat keys as fresh
|
|
790
1143
|
return true;
|
|
791
1144
|
}
|
|
792
1145
|
}
|
|
1146
|
+
|
|
1147
|
+
await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_failure');
|
|
793
1148
|
return false;
|
|
794
1149
|
}
|
|
795
1150
|
|
|
@@ -843,10 +1198,20 @@ async function requestSmsCode(store, method, opts) {
|
|
|
843
1198
|
while (true) {
|
|
844
1199
|
// Rebuilt per attempt so client_metrics carries the current attempt count.
|
|
845
1200
|
const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
|
|
1201
|
+
|
|
1202
|
+
// The screen a later event belongs to is named after the method asked
|
|
1203
|
+
// for here, so record it before the request rather than after.
|
|
1204
|
+
store._lastRequestedMethod = m;
|
|
1205
|
+
const verifyScreen = currentVerifyScreen(store);
|
|
1206
|
+
await sendFunnelLog(store, waVersion, verifyScreen, 'request_code', 'request_code_attempt');
|
|
1207
|
+
|
|
846
1208
|
const result = await sendRequest('/code', store, waVersion, true, extra);
|
|
847
1209
|
|
|
848
1210
|
const status = result.status;
|
|
849
|
-
if (status === 'ok' || status === 'sent')
|
|
1211
|
+
if (status === 'ok' || status === 'sent') {
|
|
1212
|
+
await sendFunnelLog(store, waVersion, verifyScreen, 'request_code', 'request_code_success');
|
|
1213
|
+
return result;
|
|
1214
|
+
}
|
|
850
1215
|
|
|
851
1216
|
const reason = result.reason || status || '';
|
|
852
1217
|
|
|
@@ -903,16 +1268,23 @@ async function requestSmsCode(store, method, opts) {
|
|
|
903
1268
|
return result;
|
|
904
1269
|
}
|
|
905
1270
|
|
|
906
|
-
async function verifyCode(store, code) {
|
|
1271
|
+
async function verifyCode(store, code, opts) {
|
|
1272
|
+
opts = opts || {};
|
|
907
1273
|
const _device = getDeviceConfig();
|
|
908
1274
|
const waVersion = await fetchWaVersion(_device);
|
|
909
1275
|
store.version = waVersion;
|
|
910
1276
|
store.device = _device;
|
|
911
1277
|
const normalized = code.replace(/[\s\-]/g, '').replace(/\D/g, '');
|
|
1278
|
+
|
|
1279
|
+
const screen = currentVerifyScreen(store);
|
|
1280
|
+
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
1281
|
+
|
|
912
1282
|
const result = await sendRequest('/register', store, waVersion, true, ['code', normalized]);
|
|
913
1283
|
|
|
914
1284
|
const status = result.status;
|
|
915
1285
|
if (status === 'ok' || status === 'sent' || status === 'verified') {
|
|
1286
|
+
await sendFunnelLog(store, waVersion, 'account_verification_complete',
|
|
1287
|
+
'submit_code', 'submit_code_success');
|
|
916
1288
|
// Adopt the number in the server's own form.
|
|
917
1289
|
//
|
|
918
1290
|
// WhatsApp answers with `login`, the canonical digits it filed the account
|
|
@@ -921,15 +1293,16 @@ async function verifyCode(store, code) {
|
|
|
921
1293
|
// under the eight-digit form, and a session saved under the typed number
|
|
922
1294
|
// sends a username on every connection that matches no registration. The
|
|
923
1295
|
// handshake then fails with 401 and nothing in the message hints at why.
|
|
924
|
-
|
|
925
|
-
if (canonical && canonical !== String(store.phoneNumber)) {
|
|
926
|
-
result.canonicalPhoneNumber = canonical;
|
|
927
|
-
result.typedPhoneNumber = String(store.phoneNumber);
|
|
928
|
-
store.phoneNumber = canonical;
|
|
929
|
-
}
|
|
930
|
-
return result;
|
|
1296
|
+
return adoptCanonicalNumber(store, result);
|
|
931
1297
|
}
|
|
932
1298
|
|
|
1299
|
+
// Before reading the reason, because neither of these arrives as one. A
|
|
1300
|
+
// captcha comes as image and audio blobs with nothing else to go on, and a
|
|
1301
|
+
// PIN demand names a reason that none of the branches below would match —
|
|
1302
|
+
// both would otherwise fall through to the generic failure.
|
|
1303
|
+
if (hasChallenge(result)) return handleChallenge(store, waVersion, result, opts);
|
|
1304
|
+
if (is2FARequired(result)) return handle2FA(store, waVersion, opts);
|
|
1305
|
+
|
|
933
1306
|
const reason = result.reason || '';
|
|
934
1307
|
if (reason === 'missing') {
|
|
935
1308
|
throw new Error(
|
|
@@ -943,13 +1316,56 @@ async function verifyCode(store, code) {
|
|
|
943
1316
|
if (/too_many/.test(reason)) {
|
|
944
1317
|
throw new Error('Verification failed: too many wrong attempts. Wait a few minutes then request a new code.');
|
|
945
1318
|
}
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
1319
|
+
// Age / consent gate.
|
|
1320
|
+
//
|
|
1321
|
+
// The server found the account and did not complain about the code — it is
|
|
1322
|
+
// asking for an age signal that only a real app-store install carries, and
|
|
1323
|
+
// refusing to finish without it. Brazil is where this shows up, which fits:
|
|
1324
|
+
// it is the market with an age-verification law behind it.
|
|
1325
|
+
//
|
|
1326
|
+
// Worth knowing before trying anything: the iOS request carries six fields
|
|
1327
|
+
// and not one of them says anything about consent, terms or age. The Android
|
|
1328
|
+
// one carries tos_version, education_screen_displayed and
|
|
1329
|
+
// clicked_education_link. That is the difference to try first, and it costs
|
|
1330
|
+
// nothing to try.
|
|
949
1331
|
if (result.pending === 'app_store_age' || reason === 'consent') {
|
|
950
|
-
|
|
1332
|
+
const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
|
|
1333
|
+
const lines = [
|
|
1334
|
+
'Verification failed: WhatsApp wants an age-consent signal for this number ' +
|
|
1335
|
+
'and will not finish the registration without one' +
|
|
1336
|
+
(result.pending ? ' (pending: ' + result.pending + ')' : '') + '.',
|
|
1337
|
+
' The code itself was not refused — the account was found' +
|
|
1338
|
+
(canonical ? ', filed as +' + canonical : '') + '.'
|
|
1339
|
+
];
|
|
1340
|
+
if (canonical && canonical !== String(store.phoneNumber)) {
|
|
1341
|
+
lines.push(' Note the digits: you typed +' + store.phoneNumber +
|
|
1342
|
+
', WhatsApp keeps it as +' + canonical + '. Brazilian mobiles gained a ' +
|
|
1343
|
+
'ninth digit that WhatsApp did not adopt, and that alone is not the failure here.');
|
|
1344
|
+
}
|
|
1345
|
+
lines.push(
|
|
1346
|
+
' Try registering as an Android device instead — that request carries the ' +
|
|
1347
|
+
'terms and education fields the iOS one has none of:',
|
|
1348
|
+
' WA_OS=android wa registration --code ' + store.phoneNumber,
|
|
1349
|
+
' If that is refused too, this number needs the real app once, on a phone, ' +
|
|
1350
|
+
'to clear the gate.'
|
|
1351
|
+
);
|
|
1352
|
+
const err = new Error(lines.join('\n'));
|
|
1353
|
+
err.reason = reason;
|
|
1354
|
+
err.pending = result.pending || null;
|
|
1355
|
+
err.canonicalPhoneNumber = canonical;
|
|
1356
|
+
err.raw = result;
|
|
1357
|
+
throw err;
|
|
951
1358
|
}
|
|
952
1359
|
throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
|
|
953
1360
|
}
|
|
954
1361
|
|
|
955
1362
|
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
1363
|
+
|
|
1364
|
+
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1365
|
+
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
1366
|
+
|
|
1367
|
+
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1368
|
+
module.exports._verify = {
|
|
1369
|
+
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
1370
|
+
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled
|
|
1371
|
+
};
|