whalibmob 5.14.15 → 5.14.17
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 +7 -0
- package/README.md +33 -0
- package/cli.js +26 -6
- package/lib/Client.js +82 -3
- package/lib/Registration.js +101 -6
- package/lib/Store.js +7 -0
- package/lib/signal/SenderKey.js +116 -15
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -116,6 +116,13 @@
|
|
|
116
116
|
# WA_ALLOW_FOREIGN_APK=1
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
# ─── Flash call verification ─────────────────────────────────────────────────
|
|
120
|
+
# --method flash has WhatsApp ring the number and hang up; the code is the last
|
|
121
|
+
# digits of the number that called. Six is what the app defaults to; set this
|
|
122
|
+
# only for a number whose server expects a different length.
|
|
123
|
+
# WA_FLASH_CODE_LEN=6
|
|
124
|
+
|
|
125
|
+
|
|
119
126
|
# ─── Proxy ───────────────────────────────────────────────────────────────────
|
|
120
127
|
# Route registration traffic through a SOCKS proxy. Requires the `socks`
|
|
121
128
|
# package: npm install socks
|
package/README.md
CHANGED
|
@@ -292,8 +292,41 @@ wa registration --request-code 919634847671 --method voice
|
|
|
292
292
|
|
|
293
293
|
# via an old WhatsApp account
|
|
294
294
|
wa registration --request-code 919634847671 --method wa_old
|
|
295
|
+
|
|
296
|
+
# via flash call — WhatsApp rings the number and hangs up (Android only)
|
|
297
|
+
WA_OS=android wa registration --request-code 919634847671 --method flash
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Flash call** sends no code anywhere. WhatsApp rings the number from a
|
|
301
|
+
one-time number and drops the call before it can be answered, and the
|
|
302
|
+
verification code *is* that number — its last 6 digits. The official Android app
|
|
303
|
+
reads them out of the call log; here the person holding the phone reads the
|
|
304
|
+
missed call instead and types them:
|
|
305
|
+
|
|
306
|
+
```sh
|
|
307
|
+
# phone shows a missed call from +40 21 555 123456
|
|
308
|
+
wa registration --register 919634847671 --code 123456
|
|
295
309
|
```
|
|
296
310
|
|
|
311
|
+
Pasting the whole number works too — only its last six digits are sent. Do not
|
|
312
|
+
answer the call; it hangs up on its own, and answering it does not verify
|
|
313
|
+
anything.
|
|
314
|
+
|
|
315
|
+
Three things to know before choosing it:
|
|
316
|
+
|
|
317
|
+
- **Android only.** iOS has no API for reading an incoming call's number, so
|
|
318
|
+
WhatsApp never offers flash there. Asking for it as iOS is refused before any
|
|
319
|
+
request goes out, so the number spends no attempt learning that.
|
|
320
|
+
- **The server decides.** Flash is not offered for every number or country. When
|
|
321
|
+
it declines, the request falls back to SMS automatically and the session
|
|
322
|
+
records that, so the SMS code is confirmed as an SMS code rather than being
|
|
323
|
+
trimmed like a caller ID.
|
|
324
|
+
- **The caller ID has to be visible.** A carrier that withholds the calling
|
|
325
|
+
number leaves nothing to read.
|
|
326
|
+
|
|
327
|
+
Set `WA_FLASH_CODE_LEN` if a number's server expects a different length than the
|
|
328
|
+
six digits the app defaults to.
|
|
329
|
+
|
|
297
330
|
**Set the account's display name while registering** with `--name`. This is the
|
|
298
331
|
name people who have *not* saved your number see next to it — in group
|
|
299
332
|
participant lists, in notifications, and beside your messages. Quote it if it
|
package/cli.js
CHANGED
|
@@ -484,6 +484,26 @@ function normalizePhone(s) {
|
|
|
484
484
|
return String(s || '').replace(/^\+/, '').replace(/\D/g, '');
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
// What to tell the person once a code request has gone through. A flash call
|
|
488
|
+
// needs its own words: nothing arrives in a message, so "enter the code" names
|
|
489
|
+
// something they will never receive. What they get is a call that stops before
|
|
490
|
+
// it can be answered, and the code is the number it came from.
|
|
491
|
+
//
|
|
492
|
+
// The method printed is the one that actually went out — a flash request the
|
|
493
|
+
// server declined and fell back to SMS has to say SMS, or the instructions
|
|
494
|
+
// describe a call that is never coming.
|
|
495
|
+
function printCodeNextSteps(store, phone, confirmCmd) {
|
|
496
|
+
if (store && store.codeMethod === 'flash') {
|
|
497
|
+
out(' a call will ring +' + phone + ' and hang up by itself — do not answer it');
|
|
498
|
+
out(' the code is the LAST 6 DIGITS of the number that called');
|
|
499
|
+
out(' pasting the whole number works too — only its last 6 digits are sent');
|
|
500
|
+
out(' run: ' + confirmCmd + ' <last-6-digits>');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
out(' important: enter the code within 10 minutes');
|
|
504
|
+
out(' run: ' + confirmCmd + ' <code>');
|
|
505
|
+
}
|
|
506
|
+
|
|
487
507
|
function normalizeJid(s) {
|
|
488
508
|
if (!s) return null;
|
|
489
509
|
s = String(s);
|
|
@@ -2236,7 +2256,7 @@ async function handleLine(line) {
|
|
|
2236
2256
|
// email method: /reg code <phone> email <address>
|
|
2237
2257
|
const emailAddr = method === 'email' ? (p[4] || '') : '';
|
|
2238
2258
|
if (!ph) {
|
|
2239
|
-
fail('usage: /reg code <phone> [sms|voice|wa_old|email <address>] [--name "Your Name"]');
|
|
2259
|
+
fail('usage: /reg code <phone> [sms|voice|wa_old|flash|email <address>] [--name "Your Name"]');
|
|
2240
2260
|
out(' --name sets the display name the account registers with — what people');
|
|
2241
2261
|
out(' who have not saved your number see. It can be changed later with /name.');
|
|
2242
2262
|
break;
|
|
@@ -2272,8 +2292,7 @@ async function handleLine(line) {
|
|
|
2272
2292
|
store.codePending = true;
|
|
2273
2293
|
saveStore(store, sessFile);
|
|
2274
2294
|
out(' status ' + (r && r.status));
|
|
2275
|
-
|
|
2276
|
-
out(' now run: /reg confirm ' + ph + ' <code>');
|
|
2295
|
+
printCodeNextSteps(store, ph, '/reg confirm ' + ph);
|
|
2277
2296
|
}
|
|
2278
2297
|
else if (sub === 'confirm') {
|
|
2279
2298
|
const ph = normalizePhone(p[2]);
|
|
@@ -2544,7 +2563,9 @@ options:
|
|
|
2544
2563
|
--out <file> where apk-material writes (default: <session dir>/android-apk-material.json)
|
|
2545
2564
|
--sms connect by registering this number over SMS
|
|
2546
2565
|
--pair connect by linking to an existing account (8-digit code)
|
|
2547
|
-
--method sms | voice | wa_old | email (default: sms)
|
|
2566
|
+
--method sms | voice | wa_old | flash | email (default: sms)
|
|
2567
|
+
flash: WhatsApp rings the number and hangs up; the code is
|
|
2568
|
+
the last 6 digits of the calling number (Android only)
|
|
2548
2569
|
--email <address> email address (required when --method email)
|
|
2549
2570
|
--business register/connect as WhatsApp Business (same as WA_BUSINESS=1)
|
|
2550
2571
|
--all refresh-version: every session in the session directory
|
|
@@ -2920,8 +2941,7 @@ async function main() {
|
|
|
2920
2941
|
saveStore(store, sessFile);
|
|
2921
2942
|
out(' status ' + (r && r.status));
|
|
2922
2943
|
if (r && (r.status === 'sent' || r.status === 'ok')) {
|
|
2923
|
-
|
|
2924
|
-
out(' run: wa registration --register ' + ph + ' --code <code>');
|
|
2944
|
+
printCodeNextSteps(store, ph, 'wa registration --register ' + ph + ' --code');
|
|
2925
2945
|
}
|
|
2926
2946
|
} catch (e) {
|
|
2927
2947
|
out(' ' + (e.message || String(e)));
|
package/lib/Client.js
CHANGED
|
@@ -2327,6 +2327,56 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2327
2327
|
return out;
|
|
2328
2328
|
}
|
|
2329
2329
|
|
|
2330
|
+
// Every address a group participant might be filed under.
|
|
2331
|
+
//
|
|
2332
|
+
// Deliberately not _signalJidCandidates: that one starts from the stanza's
|
|
2333
|
+
// `from`, which in a group is the group itself and is not a Signal address at
|
|
2334
|
+
// all. Here the sender is the only subject, under both of the names the same
|
|
2335
|
+
// person answers to.
|
|
2336
|
+
_senderAddressCandidates(senderJid, senderPn) {
|
|
2337
|
+
const out = [];
|
|
2338
|
+
const add = (jid) => {
|
|
2339
|
+
if (!jid) return;
|
|
2340
|
+
const str = String(jid);
|
|
2341
|
+
if (str && !out.includes(str)) out.push(str);
|
|
2342
|
+
};
|
|
2343
|
+
add(senderJid);
|
|
2344
|
+
add(this._counterpartJid(senderJid));
|
|
2345
|
+
add(senderPn);
|
|
2346
|
+
add(this._counterpartJid(senderPn));
|
|
2347
|
+
return out;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// The sender key for a group message, under whichever name it was filed.
|
|
2351
|
+
//
|
|
2352
|
+
// A SenderKeyName keeps the user and the device and drops the domain, so the
|
|
2353
|
+
// LID form and the phone form of one person are two different names for one
|
|
2354
|
+
// key. Which of them a message arrives under is the server's choice and it
|
|
2355
|
+
// varies, so a key stored under the name one stanza used is invisible to the
|
|
2356
|
+
// next stanza that names the other. That is the whole of "No session found to
|
|
2357
|
+
// decrypt message" on a group we have only just joined.
|
|
2358
|
+
async _senderKeyDecryptWithCandidates(groupJid, candidates, cipherBuf, id) {
|
|
2359
|
+
let firstErr = null;
|
|
2360
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2361
|
+
try {
|
|
2362
|
+
const plaintext = await this._signal.senderKeyDecrypt(groupJid, candidates[i], cipherBuf);
|
|
2363
|
+
if (i > 0) {
|
|
2364
|
+
_whaDbg('[DBG] GROUP_DECRYPT id=' + id + ' recovered under ' + candidates[i] +
|
|
2365
|
+
' after ' + candidates[0] + ' held no sender key');
|
|
2366
|
+
}
|
|
2367
|
+
return plaintext;
|
|
2368
|
+
} catch (err) {
|
|
2369
|
+
if (!firstErr) firstErr = err;
|
|
2370
|
+
// Only "we hold nothing under this name" is worth asking another name
|
|
2371
|
+
// about. A record that exists and refuses the message is a different
|
|
2372
|
+
// problem, and the answer to that is a retry receipt, not another
|
|
2373
|
+
// lookup.
|
|
2374
|
+
if (!/no session found|no senderkeyrecord/i.test(String(err && err.message))) break;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
throw firstErr || new Error('senderKeyDecrypt: no address to try');
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2330
2380
|
// Try each address in turn and return the first plaintext.
|
|
2331
2381
|
//
|
|
2332
2382
|
// A failed attempt leaves nothing behind — libsignal either returns the
|
|
@@ -2458,8 +2508,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2458
2508
|
? skmsgNode.content
|
|
2459
2509
|
: Buffer.from(skmsgNode.content || '');
|
|
2460
2510
|
|
|
2511
|
+
const senderCandidates = this._senderAddressCandidates(participant);
|
|
2512
|
+
_whaDbg('[DBG] GROUP_DECRYPT id=' + id + ' group=' + from + ' sender=' + participant +
|
|
2513
|
+
(senderCandidates.length > 1 ? ' alts=' + senderCandidates.slice(1).join(',') : '') +
|
|
2514
|
+
' cipherLen=' + cipherBuf.length);
|
|
2515
|
+
|
|
2461
2516
|
// Returned for the same reason as the direct-message path above.
|
|
2462
|
-
return this.
|
|
2517
|
+
return this._senderKeyDecryptWithCandidates(from, senderCandidates, cipherBuf, id)
|
|
2463
2518
|
.then(plaintext => {
|
|
2464
2519
|
this._resolveRetry(id);
|
|
2465
2520
|
const decoded = this._decodeMsg(plaintext);
|
|
@@ -2525,19 +2580,43 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2525
2580
|
const encType = enc.attrs && enc.attrs.type;
|
|
2526
2581
|
const cipherBuf = Buffer.isBuffer(enc.content) ? enc.content : Buffer.from(enc.content || '');
|
|
2527
2582
|
|
|
2583
|
+
// The sender key rides inside a 1-to-1 message, so opening it needs the
|
|
2584
|
+
// session with that participant — and the session may be filed under the
|
|
2585
|
+
// other name they answer to. Addressing only the name this stanza used is
|
|
2586
|
+
// how joining a group ended with every message failing: the distribution
|
|
2587
|
+
// could not be opened, so no sender key was ever stored, so every skmsg
|
|
2588
|
+
// after it had nothing to decrypt with.
|
|
2589
|
+
const senderCandidates = this._senderAddressCandidates(senderJid);
|
|
2590
|
+
|
|
2528
2591
|
tasks.push(
|
|
2529
|
-
this.
|
|
2592
|
+
this._decryptWithCandidates(senderCandidates, encType, cipherBuf, 'skdm')
|
|
2530
2593
|
.then(async decrypted => {
|
|
2531
2594
|
const decoded = this._decodeMsg(decrypted);
|
|
2595
|
+
// Filed under the name this stanza used, which is the name the
|
|
2596
|
+
// group message that follows will look it up under.
|
|
2532
2597
|
if (decoded && decoded.type === 'senderKeyDistribution' &&
|
|
2533
2598
|
decoded.groupId && decoded.axolotlBytes) {
|
|
2534
2599
|
await this._signal.processSKDM(decoded.groupId, senderJid, decoded.axolotlBytes);
|
|
2600
|
+
_whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.groupId + ' sender=' + senderJid);
|
|
2535
2601
|
} else if (decoded && decoded.skdm &&
|
|
2536
2602
|
decoded.skdm.groupId && decoded.skdm.axolotlBytes) {
|
|
2537
2603
|
await this._signal.processSKDM(decoded.skdm.groupId, senderJid, decoded.skdm.axolotlBytes);
|
|
2604
|
+
_whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.skdm.groupId + ' sender=' + senderJid);
|
|
2605
|
+
} else {
|
|
2606
|
+
_whaDbg('[DBG] SKDM_DIST_NONE sender=' + senderJid +
|
|
2607
|
+
' — the <to> entry for us carried no distribution');
|
|
2538
2608
|
}
|
|
2539
2609
|
})
|
|
2540
|
-
.
|
|
2610
|
+
// Said out loud rather than swallowed. This failing is the reason the
|
|
2611
|
+
// group messages that follow cannot be read, and it used to leave no
|
|
2612
|
+
// trace at all — only the downstream failures were visible, which
|
|
2613
|
+
// pointed at the wrong thing.
|
|
2614
|
+
.catch(err => {
|
|
2615
|
+
_whaDbg('[DBG] SKDM_DIST_ERR sender=' + senderJid +
|
|
2616
|
+
' tried=[' + senderCandidates.join(',') + ']' +
|
|
2617
|
+
' err=' + (err && err.message));
|
|
2618
|
+
this.emit('skdm_error', { group: groupJid, sender: senderJid, err });
|
|
2619
|
+
})
|
|
2541
2620
|
);
|
|
2542
2621
|
}
|
|
2543
2622
|
|
package/lib/Registration.js
CHANGED
|
@@ -1154,6 +1154,12 @@ function buildClientMetrics(attempt) {
|
|
|
1154
1154
|
|
|
1155
1155
|
function getRequestVerificationCodeParameters(store, method, meta, device, attempt) {
|
|
1156
1156
|
if (device && device.os === 'android') {
|
|
1157
|
+
// Flash call is the one method whose delivery the server only routes when
|
|
1158
|
+
// the client says it can observe an incoming call. The three fields below
|
|
1159
|
+
// are that statement, and asking for `flash` while any of them still says
|
|
1160
|
+
// otherwise gets the request answered with an SMS — or with no route at
|
|
1161
|
+
// all. They stay at their headless-safe values for every other method.
|
|
1162
|
+
const wantsFlash = method === 'flash';
|
|
1157
1163
|
return [
|
|
1158
1164
|
'method', method,
|
|
1159
1165
|
'sim_mcc', meta.mcc,
|
|
@@ -1166,7 +1172,7 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1166
1172
|
'sim_type', '1',
|
|
1167
1173
|
'recaptcha', '%7B%22stage%22%3A%22ABPROP_DISABLED%22%7D',
|
|
1168
1174
|
'network_radio_type', '1',
|
|
1169
|
-
'prefer_sms_over_flash', 'true',
|
|
1175
|
+
'prefer_sms_over_flash', wantsFlash ? 'false' : 'true',
|
|
1170
1176
|
'simnum', '0',
|
|
1171
1177
|
'airplane_mode_type', '0',
|
|
1172
1178
|
'client_metrics', buildClientMetrics(attempt),
|
|
@@ -1180,8 +1186,8 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1180
1186
|
'cellular_strength', '5',
|
|
1181
1187
|
'backup_token', store.backupToken ? toUrlHex(store.backupToken) : '',
|
|
1182
1188
|
'tos_version', '5',
|
|
1183
|
-
'call_log_permission', 'false',
|
|
1184
|
-
'manage_call_permission', 'false',
|
|
1189
|
+
'call_log_permission', wantsFlash ? 'true' : 'false',
|
|
1190
|
+
'manage_call_permission', wantsFlash ? 'true' : 'false',
|
|
1185
1191
|
'clicked_education_link', 'false',
|
|
1186
1192
|
'aid', '',
|
|
1187
1193
|
// Omitted unless a push client supplies a real code — an empty value is
|
|
@@ -1545,6 +1551,54 @@ function normalizeCodeResult(code) {
|
|
|
1545
1551
|
return String(code == null ? '' : code).replace(/[\s\-]/g, '').replace(/\D/g, '');
|
|
1546
1552
|
}
|
|
1547
1553
|
|
|
1554
|
+
// A flash call carries no code of its own: WhatsApp rings the number from a
|
|
1555
|
+
// one-time caller ID and drops the call, and the verification code IS that
|
|
1556
|
+
// number — its trailing digits. The official Android app reads them out of the
|
|
1557
|
+
// call log, which is a thing no server-side client has; here the person holding
|
|
1558
|
+
// the handset reads the missed call instead and types what they see.
|
|
1559
|
+
//
|
|
1560
|
+
// So whatever arrives has to be reduced to those digits. Someone reading a
|
|
1561
|
+
// missed call types it however their phone showed it — "+40 21 555 123456",
|
|
1562
|
+
// "0040...", or already just the last six — and all three have to land on the
|
|
1563
|
+
// same code. Digits are taken first, then the tail: a full number is longer
|
|
1564
|
+
// than the code, and its END is the part the server compares.
|
|
1565
|
+
//
|
|
1566
|
+
// Six is what the decompiled app defaults to. Flash-call providers vary between
|
|
1567
|
+
// four and six, so it is overridable rather than baked in, for the number whose
|
|
1568
|
+
// server wants a different length.
|
|
1569
|
+
const FLASH_CODE_LENGTH = 6;
|
|
1570
|
+
|
|
1571
|
+
function flashCodeLength() {
|
|
1572
|
+
const raw = parseInt(process.env.WA_FLASH_CODE_LEN, 10);
|
|
1573
|
+
return raw > 0 ? raw : FLASH_CODE_LENGTH;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function flashCodeFromCallerId(input, len) {
|
|
1577
|
+
const digits = normalizeCodeResult(input);
|
|
1578
|
+
const want = len > 0 ? len : flashCodeLength();
|
|
1579
|
+
// Shorter than the code means the caller already typed just the tail (or the
|
|
1580
|
+
// handset showed a short number); there is nothing to trim and guessing would
|
|
1581
|
+
// only corrupt it.
|
|
1582
|
+
return digits.length > want ? digits.slice(-want) : digits;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// The digits /register is given, for whichever method the pending code came by.
|
|
1586
|
+
// Split out so the choice can be tested on its own: it is the whole difference
|
|
1587
|
+
// between confirming a flash call and confirming anything else, and it runs at
|
|
1588
|
+
// a point in verifyCode that a test cannot otherwise reach without a network.
|
|
1589
|
+
//
|
|
1590
|
+
// The method is read off the session because the request and the confirmation
|
|
1591
|
+
// are usually two separate commands; opts.method overrides it for a caller that
|
|
1592
|
+
// knows better, or for a session saved before the field existed.
|
|
1593
|
+
function codeForSubmission(store, code, opts) {
|
|
1594
|
+
const pending = String(
|
|
1595
|
+
(opts && opts.method) || (store && store.codeMethod) || ''
|
|
1596
|
+
).toLowerCase();
|
|
1597
|
+
return pending === 'flash'
|
|
1598
|
+
? flashCodeFromCallerId(code)
|
|
1599
|
+
: normalizeCodeResult(code);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1548
1602
|
// Take the digits the server filed the account under. Brazilian mobiles are the
|
|
1549
1603
|
// standing example: they gained a ninth digit WhatsApp never adopted, and a
|
|
1550
1604
|
// session saved under the typed number sends a username on every connection
|
|
@@ -1831,6 +1885,20 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1831
1885
|
throw new Error('requestSmsCode: email method requires opts.email address');
|
|
1832
1886
|
}
|
|
1833
1887
|
|
|
1888
|
+
// Flash exists on Android and nowhere else. iOS has no public way to read an
|
|
1889
|
+
// incoming call's number, so the platform never offers the method and asking
|
|
1890
|
+
// for it as iOS is answered with an ordinary SMS at best. Refusing here costs
|
|
1891
|
+
// nothing; letting it through spends one of the number's attempts to learn
|
|
1892
|
+
// the same thing from the server.
|
|
1893
|
+
if (method === 'flash' && !(_device && _device.os === 'android')) {
|
|
1894
|
+
throw new Error(
|
|
1895
|
+
'Flash call verification is Android-only — iOS has no API for reading an ' +
|
|
1896
|
+
'incoming call\'s number, so WhatsApp does not offer it there.\n' +
|
|
1897
|
+
' Re-run as Android: WA_OS=android wa registration --request-code ' +
|
|
1898
|
+
store.phoneNumber + ' --method flash'
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1834
1902
|
// Auto-fallback: if the primary method gets no_routes, try the alternate once.
|
|
1835
1903
|
// sms → wa_old, wa_old → sms, voice → sms. Never for email: falling back
|
|
1836
1904
|
// would send an SMS to somebody who asked for an email.
|
|
@@ -1867,6 +1935,13 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1867
1935
|
|
|
1868
1936
|
const status = result.status;
|
|
1869
1937
|
if (status === 'ok' || status === 'sent') {
|
|
1938
|
+
// Which method actually went through, not which one was asked for: a
|
|
1939
|
+
// flash request that fell back to SMS leaves a code in an SMS, and
|
|
1940
|
+
// confirming it as flash would take the last six digits of a six-digit
|
|
1941
|
+
// code — right by accident here, wrong the moment the lengths differ.
|
|
1942
|
+
// The confirmation step is usually a separate command, so this is
|
|
1943
|
+
// written to the session rather than kept in memory.
|
|
1944
|
+
store.codeMethod = m;
|
|
1870
1945
|
await sendFunnelLog(store, waVersion, verifyScreen, 'request_code', 'request_code_success');
|
|
1871
1946
|
return result;
|
|
1872
1947
|
}
|
|
@@ -1935,7 +2010,9 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1935
2010
|
`code (tried: ${tried}).\n` +
|
|
1936
2011
|
` • Most often the IP: datacenter, VPS and VPN addresses are refused. ` +
|
|
1937
2012
|
`Use a residential connection, or SOCKS_PROXY=socks5://…\n` +
|
|
1938
|
-
` • Try another method: --method voice
|
|
2013
|
+
` • Try another method: --method voice, or --method flash ` +
|
|
2014
|
+
`(WA_OS=android — rings the number and hangs up; the code is the ` +
|
|
2015
|
+
`last 6 digits of the calling number)\n` +
|
|
1939
2016
|
` • Try the other platform: WA_OS=ios (routes differ per platform)\n` +
|
|
1940
2017
|
` • wa_old only works if the number is already active on WhatsApp\n` +
|
|
1941
2018
|
` • Real SIM numbers fare better than virtual or VoIP ones\n` +
|
|
@@ -1959,7 +2036,23 @@ async function verifyCode(store, code, opts) {
|
|
|
1959
2036
|
store.device = _device;
|
|
1960
2037
|
// A verification code is all digits, so callers reasonably hand one over as a
|
|
1961
2038
|
// number; coercing here keeps that from dying on .replace before we ask.
|
|
1962
|
-
|
|
2039
|
+
//
|
|
2040
|
+
// Flash is the exception: nothing was ever sent to this number, so what the
|
|
2041
|
+
// caller has is the missed call's own number and the code is its tail. Which
|
|
2042
|
+
// method is pending is remembered from the code request — the two steps are
|
|
2043
|
+
// usually separate commands, so it is read back off the session rather than
|
|
2044
|
+
// assumed to still be in memory. opts.method lets a caller state it outright
|
|
2045
|
+
// for a session that predates the field.
|
|
2046
|
+
const pendingMethod = String(opts.method || store.codeMethod || '').toLowerCase();
|
|
2047
|
+
const normalized = codeForSubmission(store, code, opts);
|
|
2048
|
+
|
|
2049
|
+
if (pendingMethod === 'flash') {
|
|
2050
|
+
const typed = normalizeCodeResult(code);
|
|
2051
|
+
if (typed.length !== normalized.length) {
|
|
2052
|
+
_whaDbg('[DBG] REG flash caller-id ' + typed.length + ' digits → code ' +
|
|
2053
|
+
normalized + ' (last ' + normalized.length + ')');
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
1963
2056
|
|
|
1964
2057
|
const screen = currentVerifyScreen(store);
|
|
1965
2058
|
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
@@ -2055,5 +2148,7 @@ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders
|
|
|
2055
2148
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
2056
2149
|
module.exports._verify = {
|
|
2057
2150
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
2058
|
-
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint
|
|
2151
|
+
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint,
|
|
2152
|
+
flashCodeFromCallerId, flashCodeLength, codeForSubmission,
|
|
2153
|
+
getRequestVerificationCodeParameters
|
|
2059
2154
|
};
|
package/lib/Store.js
CHANGED
|
@@ -195,6 +195,12 @@ function storeToJson(store) {
|
|
|
195
195
|
backupToken: store.backupToken ? store.backupToken.toString('base64') : null,
|
|
196
196
|
registered: !!store.registered,
|
|
197
197
|
codePending: store.codePending || false,
|
|
198
|
+
// Which delivery method the pending code actually went out by. The two
|
|
199
|
+
// registration steps are normally separate commands, so the confirmation
|
|
200
|
+
// has no memory of the request unless it is written down — and for a flash
|
|
201
|
+
// call it has to know, since that code is read off the caller ID rather
|
|
202
|
+
// than out of a message.
|
|
203
|
+
codeMethod: store.codeMethod || null,
|
|
198
204
|
name,
|
|
199
205
|
version,
|
|
200
206
|
device,
|
|
@@ -237,6 +243,7 @@ function storeFromJson(obj) {
|
|
|
237
243
|
backupToken: obj.backupToken ? Buffer.from(obj.backupToken, 'base64') : crypto.randomBytes(20),
|
|
238
244
|
registered: !!obj.registered,
|
|
239
245
|
codePending: obj.codePending || false,
|
|
246
|
+
codeMethod: obj.codeMethod || null,
|
|
240
247
|
name,
|
|
241
248
|
version,
|
|
242
249
|
device,
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -95,6 +95,15 @@ class SenderKeyStore {
|
|
|
95
95
|
this._dirty = false;
|
|
96
96
|
this._rev = 0;
|
|
97
97
|
this._saveTimer = null;
|
|
98
|
+
// Same three as in SignalStore, for the same reason. The debounced write
|
|
99
|
+
// here was never cancelled or awaited either, so a synchronous flush could
|
|
100
|
+
// be overtaken by an older one still in flight — and this file is where the
|
|
101
|
+
// group sender keys live, including the record of who has already been sent
|
|
102
|
+
// ours. _writtenRev is what the file holds; _writing keeps the async writes
|
|
103
|
+
// to one at a time; _writeAgain remembers one that came due mid-write.
|
|
104
|
+
this._writtenRev = 0;
|
|
105
|
+
this._writing = false;
|
|
106
|
+
this._writeAgain = false;
|
|
98
107
|
|
|
99
108
|
// Detachable, for the same reason as in SignalStore: a replaced store must
|
|
100
109
|
// not keep three process listeners alive for the rest of the run.
|
|
@@ -115,7 +124,14 @@ class SenderKeyStore {
|
|
|
115
124
|
process.removeListener('SIGTERM', this._onSigTerm);
|
|
116
125
|
process.removeListener('SIGINT', this._onSigInt);
|
|
117
126
|
} catch (_) {}
|
|
118
|
-
|
|
127
|
+
// Clearing the path also tells a write still in flight to stand down — it
|
|
128
|
+
// checks the path it started with before renaming. The caller detaches in
|
|
129
|
+
// order to move these files, so leave nothing of ours in the directory it
|
|
130
|
+
// is about to rename.
|
|
131
|
+
const stale = this._filePath ? [this._tmpPath(true), this._tmpPath(false)] : [];
|
|
132
|
+
this._filePath = null;
|
|
133
|
+
this._writeAgain = false;
|
|
134
|
+
for (const f of stale) { try { fs.unlinkSync(f); } catch (_) {} }
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
attachFile(filePath) {
|
|
@@ -150,14 +166,52 @@ class SenderKeyStore {
|
|
|
150
166
|
}
|
|
151
167
|
}
|
|
152
168
|
|
|
169
|
+
// Everything that is persisted, as it goes on disk.
|
|
170
|
+
_snapshot() {
|
|
171
|
+
return JSON.stringify(this._data, null, 2);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Where a write assembles the file before it becomes the file. The sync and
|
|
175
|
+
// async writers get separate names so neither can find the other's
|
|
176
|
+
// half-written temp file under it.
|
|
177
|
+
_tmpPath(sync) {
|
|
178
|
+
return this._filePath + (sync ? '.stmp' : '.atmp');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A rename replaces the file, so the one that lands carries the temp file's
|
|
182
|
+
// permissions and not the old file's. This holds group signing keys, and an
|
|
183
|
+
// operator who tightened its mode would find it loosened again by the next
|
|
184
|
+
// write — so carry across whatever the file already had.
|
|
185
|
+
_modeOf(file) {
|
|
186
|
+
try { return fs.statSync(file).mode & 0o777; } catch (_) { return null; }
|
|
187
|
+
}
|
|
188
|
+
|
|
153
189
|
_flushSync() {
|
|
154
190
|
if (!this._dirty || !this._filePath) return;
|
|
155
191
|
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
192
|
+
const rev = this._rev;
|
|
193
|
+
const file = this._filePath;
|
|
194
|
+
const tmp = this._tmpPath(true);
|
|
156
195
|
try {
|
|
157
|
-
fs.mkdirSync(path.dirname(
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
|
|
196
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
197
|
+
const mode = this._modeOf(file);
|
|
198
|
+
fs.writeFileSync(tmp, this._snapshot(), mode !== null
|
|
199
|
+
? { encoding: 'utf8', mode } : 'utf8');
|
|
200
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
201
|
+
fs.renameSync(tmp, file);
|
|
202
|
+
// Take the async writer's temp file away from it. Its "have I been
|
|
203
|
+
// overtaken?" check runs on this thread, but the rename it authorises is
|
|
204
|
+
// carried out on a pool thread afterwards — and at exit the pool is
|
|
205
|
+
// usually busy, so that rename can still be queued now and execute during
|
|
206
|
+
// teardown, putting this snapshot back to an older one. Its callback
|
|
207
|
+
// never runs there, so nothing would notice. Removing the source makes
|
|
208
|
+
// that queued rename fail ENOENT instead.
|
|
209
|
+
try { fs.unlinkSync(this._tmpPath(false)); } catch (_) {}
|
|
210
|
+
this._writtenRev = rev;
|
|
211
|
+
this._dirty = false;
|
|
212
|
+
} catch (_) {
|
|
213
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
214
|
+
}
|
|
161
215
|
}
|
|
162
216
|
|
|
163
217
|
_save() {
|
|
@@ -167,18 +221,65 @@ class SenderKeyStore {
|
|
|
167
221
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
168
222
|
this._saveTimer = setTimeout(() => {
|
|
169
223
|
this._saveTimer = null;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
224
|
+
this._writeAsync();
|
|
225
|
+
}, _SK_DEBOUNCE_MS);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The debounced write itself: temp file, then rename into place, and only if
|
|
229
|
+
// nothing newer reached the disk in the meantime.
|
|
230
|
+
_writeAsync() {
|
|
231
|
+
if (!this._filePath || !this._dirty) return;
|
|
232
|
+
// Fold a write that comes due mid-write into the one already running rather
|
|
233
|
+
// than starting a second one over the same temp file.
|
|
234
|
+
if (this._writing) { this._writeAgain = true; return; }
|
|
235
|
+
|
|
236
|
+
// Read once, here: by the time the callbacks run either may have moved on,
|
|
237
|
+
// and that is exactly what they have to notice.
|
|
238
|
+
const rev = this._rev;
|
|
239
|
+
const file = this._filePath;
|
|
240
|
+
const tmp = this._tmpPath(false);
|
|
241
|
+
|
|
242
|
+
let data, mode;
|
|
243
|
+
try {
|
|
244
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
245
|
+
mode = this._modeOf(file);
|
|
246
|
+
data = this._snapshot();
|
|
247
|
+
} catch (_) { return; }
|
|
248
|
+
|
|
249
|
+
this._writing = true;
|
|
250
|
+
this._writeAgain = false;
|
|
251
|
+
|
|
252
|
+
const finish = () => {
|
|
253
|
+
this._writing = false;
|
|
254
|
+
// A write came due while this one was running and was folded into it.
|
|
255
|
+
// Every other mutation armed its own debounce timer, which is still
|
|
256
|
+
// pending and will write what is current.
|
|
257
|
+
if (this._writeAgain && this._dirty && this._filePath) this._writeAsync();
|
|
258
|
+
};
|
|
259
|
+
const abandon = () => { fs.unlink(tmp, () => {}); finish(); };
|
|
260
|
+
|
|
261
|
+
// Created already tight rather than widening the window in which a file of
|
|
262
|
+
// signing keys sits at the umask default.
|
|
263
|
+
fs.writeFile(tmp, data, mode !== null ? { encoding: 'utf8', mode } : 'utf8', (err) => {
|
|
264
|
+
if (err) return abandon();
|
|
265
|
+
// detach() let the file go, or a synchronous flush wrote newer state
|
|
266
|
+
// while this one was running. Renaming now would put an older snapshot
|
|
267
|
+
// back and take every sender key written since with it.
|
|
268
|
+
if (file !== this._filePath || rev <= this._writtenRev) return abandon();
|
|
269
|
+
if (mode !== null) { try { fs.chmodSync(tmp, mode); } catch (_) {} }
|
|
270
|
+
fs.rename(tmp, file, (renameErr) => {
|
|
271
|
+
if (renameErr) return abandon();
|
|
272
|
+
// Never walk it backwards: a synchronous flush can land — and record a
|
|
273
|
+
// higher revision — between the check above and this callback.
|
|
274
|
+
if (rev > this._writtenRev) this._writtenRev = rev;
|
|
275
|
+
// Only clear the flag when nothing changed while the write was running.
|
|
276
|
+
// A mutation that arrived after the snapshot is not in `data`, and
|
|
277
|
+
// clearing here would make the exit-time _flushSync() skip it and lose
|
|
278
|
+
// it.
|
|
179
279
|
if (this._rev === rev) this._dirty = false;
|
|
280
|
+
finish();
|
|
180
281
|
});
|
|
181
|
-
}
|
|
282
|
+
});
|
|
182
283
|
}
|
|
183
284
|
|
|
184
285
|
_key(groupId, senderId) {
|