whalibmob 5.11.1 → 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 +335 -15
- 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
|
//
|
|
@@ -744,6 +745,18 @@ function httpPost(path, body, waVersion, authHeader) {
|
|
|
744
745
|
|
|
745
746
|
async function sendRequest(path, store, waVersion, useToken, extraPairs) {
|
|
746
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) {
|
|
747
760
|
const enc = encryptPayload(plaintext); // base64url ENC payload (no prefix)
|
|
748
761
|
let body = 'ENC=' + enc;
|
|
749
762
|
|
|
@@ -760,6 +773,243 @@ async function sendRequest(path, store, waVersion, useToken, extraPairs) {
|
|
|
760
773
|
return httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
|
|
761
774
|
}
|
|
762
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
|
+
|
|
763
1013
|
// ---------- Public API ----------
|
|
764
1014
|
|
|
765
1015
|
async function checkIfRegistered(store) {
|
|
@@ -871,18 +1121,30 @@ async function checkNumberStatus(phoneNumber) {
|
|
|
871
1121
|
// Returns true → keys are fresh (safe to proceed with /code).
|
|
872
1122
|
// Returns false → keys already registered (generate new store before /code).
|
|
873
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
|
+
|
|
874
1128
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
875
1129
|
try {
|
|
876
1130
|
const result = await sendRequest('/exist', store, waVersion, false, null);
|
|
877
1131
|
// reason === 'incorrect' → keys not found → fresh
|
|
878
|
-
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
|
+
}
|
|
879
1136
|
// Any non-ok status on 2nd attempt → still treat as fresh (network/server glitch)
|
|
880
|
-
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
|
+
}
|
|
881
1141
|
} catch (_) {
|
|
882
1142
|
// Network error = treat keys as fresh
|
|
883
1143
|
return true;
|
|
884
1144
|
}
|
|
885
1145
|
}
|
|
1146
|
+
|
|
1147
|
+
await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_failure');
|
|
886
1148
|
return false;
|
|
887
1149
|
}
|
|
888
1150
|
|
|
@@ -936,10 +1198,20 @@ async function requestSmsCode(store, method, opts) {
|
|
|
936
1198
|
while (true) {
|
|
937
1199
|
// Rebuilt per attempt so client_metrics carries the current attempt count.
|
|
938
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
|
+
|
|
939
1208
|
const result = await sendRequest('/code', store, waVersion, true, extra);
|
|
940
1209
|
|
|
941
1210
|
const status = result.status;
|
|
942
|
-
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
|
+
}
|
|
943
1215
|
|
|
944
1216
|
const reason = result.reason || status || '';
|
|
945
1217
|
|
|
@@ -996,16 +1268,23 @@ async function requestSmsCode(store, method, opts) {
|
|
|
996
1268
|
return result;
|
|
997
1269
|
}
|
|
998
1270
|
|
|
999
|
-
async function verifyCode(store, code) {
|
|
1271
|
+
async function verifyCode(store, code, opts) {
|
|
1272
|
+
opts = opts || {};
|
|
1000
1273
|
const _device = getDeviceConfig();
|
|
1001
1274
|
const waVersion = await fetchWaVersion(_device);
|
|
1002
1275
|
store.version = waVersion;
|
|
1003
1276
|
store.device = _device;
|
|
1004
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
|
+
|
|
1005
1282
|
const result = await sendRequest('/register', store, waVersion, true, ['code', normalized]);
|
|
1006
1283
|
|
|
1007
1284
|
const status = result.status;
|
|
1008
1285
|
if (status === 'ok' || status === 'sent' || status === 'verified') {
|
|
1286
|
+
await sendFunnelLog(store, waVersion, 'account_verification_complete',
|
|
1287
|
+
'submit_code', 'submit_code_success');
|
|
1009
1288
|
// Adopt the number in the server's own form.
|
|
1010
1289
|
//
|
|
1011
1290
|
// WhatsApp answers with `login`, the canonical digits it filed the account
|
|
@@ -1014,15 +1293,16 @@ async function verifyCode(store, code) {
|
|
|
1014
1293
|
// under the eight-digit form, and a session saved under the typed number
|
|
1015
1294
|
// sends a username on every connection that matches no registration. The
|
|
1016
1295
|
// handshake then fails with 401 and nothing in the message hints at why.
|
|
1017
|
-
|
|
1018
|
-
if (canonical && canonical !== String(store.phoneNumber)) {
|
|
1019
|
-
result.canonicalPhoneNumber = canonical;
|
|
1020
|
-
result.typedPhoneNumber = String(store.phoneNumber);
|
|
1021
|
-
store.phoneNumber = canonical;
|
|
1022
|
-
}
|
|
1023
|
-
return result;
|
|
1296
|
+
return adoptCanonicalNumber(store, result);
|
|
1024
1297
|
}
|
|
1025
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
|
+
|
|
1026
1306
|
const reason = result.reason || '';
|
|
1027
1307
|
if (reason === 'missing') {
|
|
1028
1308
|
throw new Error(
|
|
@@ -1036,11 +1316,45 @@ async function verifyCode(store, code) {
|
|
|
1036
1316
|
if (/too_many/.test(reason)) {
|
|
1037
1317
|
throw new Error('Verification failed: too many wrong attempts. Wait a few minutes then request a new code.');
|
|
1038
1318
|
}
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
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.
|
|
1042
1331
|
if (result.pending === 'app_store_age' || reason === 'consent') {
|
|
1043
|
-
|
|
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;
|
|
1044
1358
|
}
|
|
1045
1359
|
throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
|
|
1046
1360
|
}
|
|
@@ -1049,3 +1363,9 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
|
|
|
1049
1363
|
|
|
1050
1364
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1051
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
|
+
};
|