whalibmob 5.14.4 → 5.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/cli.js +54 -15
- package/lib/Client.js +101 -2
- package/lib/Registration.js +13 -0
- package/lib/Store.js +27 -2
- package/lib/auth-utils.js +3 -1
- package/lib/noise.js +63 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -294,6 +294,20 @@ wa registration --request-code 919634847671 --method voice
|
|
|
294
294
|
wa registration --request-code 919634847671 --method wa_old
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
**Set the account's display name while registering** with `--name`. This is the
|
|
298
|
+
name people who have *not* saved your number see next to it — in group
|
|
299
|
+
participant lists, in notifications, and beside your messages. Quote it if it
|
|
300
|
+
contains spaces:
|
|
301
|
+
|
|
302
|
+
```sh
|
|
303
|
+
wa registration --request-code 919634847671 --name "Ricardo Trade"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The name is stored with the session and announced on every connection from then
|
|
307
|
+
on. Registration itself carries no name — the server learns it when the session
|
|
308
|
+
connects — so a number registered without `--name` has none until one is set.
|
|
309
|
+
It can be given at either registration step, and changed later with `/name`.
|
|
310
|
+
|
|
297
311
|
**Registering as Android** is the same command with the platform named. Nothing
|
|
298
312
|
else to prepare — see [what it does behind that one command](#registering-as-android).
|
|
299
313
|
|
|
@@ -1378,6 +1392,10 @@ wa> /reg code 919634847671
|
|
|
1378
1392
|
wa> /reg code 919634847671 voice
|
|
1379
1393
|
wa> /reg code 919634847671 wa_old
|
|
1380
1394
|
|
|
1395
|
+
# register with a display name — what people who have not saved
|
|
1396
|
+
# your number see. Quote it if it has spaces.
|
|
1397
|
+
wa> /reg code 919634847671 --name "Ricardo Trade"
|
|
1398
|
+
|
|
1381
1399
|
# confirm the code received
|
|
1382
1400
|
wa> /reg confirm 919634847671 123456
|
|
1383
1401
|
registered session saved to /home/user/.waSession/919634847671/919634847671.json
|
|
@@ -1542,8 +1560,8 @@ wa> /quit
|
|
|
1542
1560
|
| `/biz <phone\|jid>` | Query business profile of a WhatsApp Business account |
|
|
1543
1561
|
| **Registration** | |
|
|
1544
1562
|
| `/reg check <phone>` | Check if number has WhatsApp |
|
|
1545
|
-
| `/reg code <phone> [method]` | Request verification code |
|
|
1546
|
-
| `/reg confirm <phone> <code
|
|
1563
|
+
| `/reg code <phone> [method] [--name "Name"]` | Request verification code, optionally naming the account |
|
|
1564
|
+
| `/reg confirm <phone> <code> [--name "Name"]` | Complete registration |
|
|
1547
1565
|
| **Connection** | |
|
|
1548
1566
|
| `/connect <phone> [sms\|pair]` | Connect to WhatsApp — asks which method when unset |
|
|
1549
1567
|
| `/pair <phone> [code]` | Link to an existing account by 8-digit pairing code |
|
|
@@ -1578,12 +1596,27 @@ const sessFile = path.join(sessDir, phone + '.json')
|
|
|
1578
1596
|
|
|
1579
1597
|
fs.mkdirSync(sessDir, { recursive: true })
|
|
1580
1598
|
|
|
1581
|
-
|
|
1599
|
+
// `name` is the display name the account registers with — what people who have
|
|
1600
|
+
// not saved the number see. Omit it and the account has no name until one is
|
|
1601
|
+
// set later with client.changeName().
|
|
1602
|
+
const store = createNewStore(phone, { name: 'Ricardo Trade' })
|
|
1582
1603
|
saveStore(store, sessFile)
|
|
1583
1604
|
|
|
1584
1605
|
await requestSmsCode(store, 'sms') // 'sms' | 'voice' | 'wa_old'
|
|
1585
1606
|
```
|
|
1586
1607
|
|
|
1608
|
+
The name is written to the session, not sent to the registration endpoint — the
|
|
1609
|
+
server learns it from the first connection, and from every one after. It can
|
|
1610
|
+
also be passed as an option at either step, which is the way to name a session
|
|
1611
|
+
that already exists:
|
|
1612
|
+
|
|
1613
|
+
```js
|
|
1614
|
+
await requestSmsCode(store, 'sms', { name: 'Ricardo Trade' })
|
|
1615
|
+
await verifyCode(store, '123456', { name: 'Ricardo Trade' })
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
Names are trimmed, collapsed to single spaces and capped at 25 characters.
|
|
1619
|
+
|
|
1587
1620
|
**Step 2 — verify the code**
|
|
1588
1621
|
|
|
1589
1622
|
```js
|
package/cli.js
CHANGED
|
@@ -202,7 +202,12 @@ function enableWireTrace() {
|
|
|
202
202
|
return origWrite(chunk, ...rest);
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
// Prepended, not appended: the socket already carries the handshake's own
|
|
206
|
+
// data listener, registered while connect() was building the socket. An
|
|
207
|
+
// appended one runs after the frame has been read, so a frame the server
|
|
208
|
+
// sent would print *after* the error it caused — or not at all, once the
|
|
209
|
+
// failure tears the connection down. The trace has to come first.
|
|
210
|
+
sock.prependListener('data', (d) => {
|
|
206
211
|
if (this.secured) return;
|
|
207
212
|
// Label positionally, but say so when the reply is plainly not a Noise
|
|
208
213
|
// frame — a proxy or captive portal answering in ASCII is worth reading
|
|
@@ -1147,6 +1152,17 @@ function requireConn() {
|
|
|
1147
1152
|
|
|
1148
1153
|
// ─── tokenizer ────────────────────────────────────────────────────────────────
|
|
1149
1154
|
|
|
1155
|
+
// Pull `--flag value` out of a token list, in place, and hand back the value.
|
|
1156
|
+
// Removing it keeps every positional argument at the index the usage line
|
|
1157
|
+
// promises, whichever end of the command the flag was typed at.
|
|
1158
|
+
function takeFlag(toks, flag) {
|
|
1159
|
+
const i = toks.indexOf(flag);
|
|
1160
|
+
if (i === -1) return null;
|
|
1161
|
+
const value = (i + 1 < toks.length && !toks[i + 1].startsWith('--')) ? toks[i + 1] : null;
|
|
1162
|
+
toks.splice(i, value === null ? 1 : 2);
|
|
1163
|
+
return value;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1150
1166
|
function tokens(line) {
|
|
1151
1167
|
const s = String(line || '');
|
|
1152
1168
|
const t = []; let b = '';
|
|
@@ -2162,6 +2178,10 @@ async function handleLine(line) {
|
|
|
2162
2178
|
// ── registration ───────────────────────────────────────────────────────
|
|
2163
2179
|
|
|
2164
2180
|
case '/reg': {
|
|
2181
|
+
// --name is pulled out before anything reads a position, so the phone
|
|
2182
|
+
// and the code stay where the usage lines say they are no matter where
|
|
2183
|
+
// the flag was typed. A name with spaces needs quoting: --name "A B".
|
|
2184
|
+
const regName = takeFlag(p, '--name');
|
|
2165
2185
|
const sub = p[1] && p[1].toLowerCase();
|
|
2166
2186
|
|
|
2167
2187
|
if (sub === 'check') {
|
|
@@ -2177,7 +2197,12 @@ async function handleLine(line) {
|
|
|
2177
2197
|
const method = (p[3] || 'sms').toLowerCase();
|
|
2178
2198
|
// email method: /reg code <phone> email <address>
|
|
2179
2199
|
const emailAddr = method === 'email' ? (p[4] || '') : '';
|
|
2180
|
-
if (!ph) {
|
|
2200
|
+
if (!ph) {
|
|
2201
|
+
fail('usage: /reg code <phone> [sms|voice|wa_old|email <address>] [--name "Your Name"]');
|
|
2202
|
+
out(' --name sets the display name the account registers with — what people');
|
|
2203
|
+
out(' who have not saved your number see. It can be changed later with /name.');
|
|
2204
|
+
break;
|
|
2205
|
+
}
|
|
2181
2206
|
if (method === 'email' && !emailAddr) {
|
|
2182
2207
|
fail('email method requires an address — usage: /reg code <phone> email <address>');
|
|
2183
2208
|
break;
|
|
@@ -2186,7 +2211,7 @@ async function handleLine(line) {
|
|
|
2186
2211
|
const sessFile = storeFileFor(_sessDir, ph);
|
|
2187
2212
|
let store = loadStore(sessFile);
|
|
2188
2213
|
if (!store) {
|
|
2189
|
-
store = initAuthCreds(ph);
|
|
2214
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2190
2215
|
saveStore(store, sessFile);
|
|
2191
2216
|
} else if (!store.codePending && !store.registered) {
|
|
2192
2217
|
// Only check /exist when keys were never used to request a code.
|
|
@@ -2195,14 +2220,16 @@ async function handleLine(line) {
|
|
|
2195
2220
|
const fresh = await assertRegistrationKeys(store, waVersion);
|
|
2196
2221
|
if (!fresh) {
|
|
2197
2222
|
out(' device keys already registered — generating new keys...');
|
|
2198
|
-
store = initAuthCreds(ph);
|
|
2223
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2199
2224
|
saveStore(store, sessFile);
|
|
2200
2225
|
out(' new keys saved — proceed with code below');
|
|
2201
2226
|
}
|
|
2202
2227
|
}
|
|
2203
2228
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2204
2229
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2205
|
-
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2230
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2231
|
+
{ onProgress: out, name: regName });
|
|
2232
|
+
if (regName) out(' registering as "' + (store.name || regName) + '"');
|
|
2206
2233
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2207
2234
|
store.codePending = true;
|
|
2208
2235
|
saveStore(store, sessFile);
|
|
@@ -2213,11 +2240,12 @@ async function handleLine(line) {
|
|
|
2213
2240
|
else if (sub === 'confirm') {
|
|
2214
2241
|
const ph = normalizePhone(p[2]);
|
|
2215
2242
|
const code = p[3];
|
|
2216
|
-
if (!ph || !code) { fail('usage: /reg confirm <phone> <code>'); break; }
|
|
2243
|
+
if (!ph || !code) { fail('usage: /reg confirm <phone> <code> [--name "Your Name"]'); break; }
|
|
2217
2244
|
const file = storeFileFor(_sessDir, ph);
|
|
2218
|
-
const store = loadStore(file) || initAuthCreds(ph);
|
|
2245
|
+
const store = loadStore(file) || initAuthCreds(ph, { name: regName });
|
|
2219
2246
|
out('verifying...');
|
|
2220
|
-
const r = await verifyCode(store, code,
|
|
2247
|
+
const r = await verifyCode(store, code,
|
|
2248
|
+
Object.assign(registrationPrompts(), { onProgress: out, name: regName }));
|
|
2221
2249
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2222
2250
|
sessionDirFor(_sessDir, ph, { create: true });
|
|
2223
2251
|
const finalStore = r.store || store;
|
|
@@ -2234,6 +2262,11 @@ async function handleLine(line) {
|
|
|
2234
2262
|
out(' the session is saved under that number');
|
|
2235
2263
|
}
|
|
2236
2264
|
out('registered session saved to ' + savedFile);
|
|
2265
|
+
if (finalStore.name && finalStore.name !== 'User') {
|
|
2266
|
+
out(' name ' + finalStore.name + ' (announced on every connect)');
|
|
2267
|
+
} else {
|
|
2268
|
+
out(' name not set — run /name <text> after connecting');
|
|
2269
|
+
}
|
|
2237
2270
|
out('now run: /connect ' + savedPhone);
|
|
2238
2271
|
} else {
|
|
2239
2272
|
fail('verification failed ' + JSON.stringify(r));
|
|
@@ -2459,8 +2492,8 @@ usage:
|
|
|
2459
2492
|
wa connect <phone> connect and open interactive shell
|
|
2460
2493
|
wa pair <phone> [code] link to an existing account (8-digit code)
|
|
2461
2494
|
wa listen <phone> connect and listen (stay-alive)
|
|
2462
|
-
wa registration --request-code <phone>
|
|
2463
|
-
wa registration --register <phone> --code <code>
|
|
2495
|
+
wa registration --request-code <phone> [--name "Your Name"]
|
|
2496
|
+
wa registration --register <phone> --code <code> [--name "Your Name"]
|
|
2464
2497
|
wa registration --check <phone>
|
|
2465
2498
|
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2466
2499
|
wa apk-material --download fetch that APK from Google Play
|
|
@@ -2485,6 +2518,7 @@ debug:
|
|
|
2485
2518
|
|
|
2486
2519
|
--debug trace without asking (same as WA_DEBUG=1)
|
|
2487
2520
|
--no-debug, -q stay quiet without asking (same as WA_DEBUG=0)
|
|
2521
|
+
--name <text> display name to register with (registration commands only)
|
|
2488
2522
|
--trace-bytes also dump the raw encoded bytes of every stanza
|
|
2489
2523
|
|
|
2490
2524
|
after connecting, type /help for all available commands.
|
|
@@ -2816,10 +2850,11 @@ async function main() {
|
|
|
2816
2850
|
// the base directory stays there, a new one gets a directory of its own.
|
|
2817
2851
|
sessionDirFor(_sessDir, ph, { create: true });
|
|
2818
2852
|
const sessFile = storeFileFor(_sessDir, ph);
|
|
2853
|
+
const regName = typeof flags.name === 'string' ? flags.name : null;
|
|
2819
2854
|
let store = loadStore(sessFile);
|
|
2820
2855
|
if (!store) {
|
|
2821
2856
|
// Brand new — generate fresh keys, save immediately, no need to check /exist
|
|
2822
|
-
store = initAuthCreds(ph);
|
|
2857
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2823
2858
|
saveStore(store, sessFile);
|
|
2824
2859
|
} else if (!store.codePending && !store.registered) {
|
|
2825
2860
|
// Existing store but code was never sent and not registered — check if
|
|
@@ -2830,7 +2865,7 @@ async function main() {
|
|
|
2830
2865
|
const fresh = await assertRegistrationKeys(store, waVersion);
|
|
2831
2866
|
if (!fresh) {
|
|
2832
2867
|
out(' device keys already registered — generating new keys...');
|
|
2833
|
-
store = initAuthCreds(ph);
|
|
2868
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2834
2869
|
saveStore(store, sessFile);
|
|
2835
2870
|
}
|
|
2836
2871
|
}
|
|
@@ -2839,7 +2874,9 @@ async function main() {
|
|
|
2839
2874
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2840
2875
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2841
2876
|
try {
|
|
2842
|
-
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2877
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2878
|
+
{ onProgress: out, name: regName });
|
|
2879
|
+
if (regName) out(' registering as "' + (store.name || regName) + '"');
|
|
2843
2880
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2844
2881
|
store.codePending = true;
|
|
2845
2882
|
saveStore(store, sessFile);
|
|
@@ -2861,11 +2898,13 @@ async function main() {
|
|
|
2861
2898
|
const code = flags.code;
|
|
2862
2899
|
if (!ph) { fail('phone number required'); process.exit(1); }
|
|
2863
2900
|
if (!code) { fail('--code is required'); process.exit(1); }
|
|
2901
|
+
const regName = typeof flags.name === 'string' ? flags.name : null;
|
|
2864
2902
|
const file = storeFileFor(_sessDir, ph);
|
|
2865
|
-
const store = loadStore(file) || initAuthCreds(ph);
|
|
2903
|
+
const store = loadStore(file) || initAuthCreds(ph, { name: regName });
|
|
2866
2904
|
out('verifying code for +' + ph + '...');
|
|
2867
2905
|
try {
|
|
2868
|
-
const r = await verifyCode(store, code,
|
|
2906
|
+
const r = await verifyCode(store, code,
|
|
2907
|
+
Object.assign(registrationPrompts(), { onProgress: out, name: regName }));
|
|
2869
2908
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2870
2909
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2871
2910
|
const finalStore = r.store || store;
|
package/lib/Client.js
CHANGED
|
@@ -676,6 +676,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
676
676
|
saveWebStore(this._store, this._webSessionFile);
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
// Write the session back to disk, whichever kind it is. A change that only
|
|
680
|
+
// lives in memory is a change that is gone at the next start.
|
|
681
|
+
_persistStore() {
|
|
682
|
+
try {
|
|
683
|
+
if (this._mode === 'web') { this._saveWebStore(); return; }
|
|
684
|
+
if (!this._sessionDir || !this._store || !this._store.phoneNumber) return;
|
|
685
|
+
const { saveStore } = require('./Store');
|
|
686
|
+
saveStore(this._store, path.join(this._sessionDir, `${this._store.phoneNumber}.json`));
|
|
687
|
+
} catch (err) {
|
|
688
|
+
_whaDbg('[DBG] STORE persist failed: ' + (err && err.message));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
679
692
|
async _connectWebSocket() {
|
|
680
693
|
const { encodeCompanionRegisterPayload, encodeCompanionLoginPayload } =
|
|
681
694
|
require('./webproto');
|
|
@@ -1279,6 +1292,26 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1279
1292
|
const attrs = node.attrs || {};
|
|
1280
1293
|
// Debug: log the full success node structure
|
|
1281
1294
|
_whaDbg('[DBG] SUCCESS node attrs=' + JSON.stringify(attrs));
|
|
1295
|
+
|
|
1296
|
+
// The one place the server ever tells us what it thinks this account is
|
|
1297
|
+
// called. Nothing else answers that question — there is no request that
|
|
1298
|
+
// reads a push name back — so this is both how the local name is kept
|
|
1299
|
+
// honest and the only way to find out whether a rename was accepted.
|
|
1300
|
+
if (attrs.display_name) {
|
|
1301
|
+
const serverName = String(attrs.display_name);
|
|
1302
|
+
if (this._store && this._store.name !== serverName) {
|
|
1303
|
+
_whaDbg('[DBG] SUCCESS display_name="' + serverName + '" — server disagrees with the ' +
|
|
1304
|
+
'stored name "' + this._store.name + '"; taking the server\'s');
|
|
1305
|
+
this._store.name = serverName;
|
|
1306
|
+
if (!this._store.pushName) this._store.pushName = serverName;
|
|
1307
|
+
this._persistStore();
|
|
1308
|
+
} else {
|
|
1309
|
+
_whaDbg('[DBG] SUCCESS display_name="' + serverName + '" — matches the stored name');
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
_whaDbg('[DBG] SUCCESS carried no display_name — the server did not say what this ' +
|
|
1313
|
+
'account is called');
|
|
1314
|
+
}
|
|
1282
1315
|
if (Array.isArray(node.content)) {
|
|
1283
1316
|
for (const child of node.content) {
|
|
1284
1317
|
if (child && child.description) {
|
|
@@ -1725,7 +1758,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1725
1758
|
}
|
|
1726
1759
|
case 'pushName': {
|
|
1727
1760
|
const name = (a.pushNameSetting && a.pushNameSetting.name) || null;
|
|
1728
|
-
|
|
1761
|
+
// Both fields, and to disk. Only `pushName` was set here, and it is
|
|
1762
|
+
// the one the store does not carry — so a rename made on another
|
|
1763
|
+
// device took effect until the process restarted, and never reached
|
|
1764
|
+
// the handshake, which announces `name`.
|
|
1765
|
+
if (name && this._store) {
|
|
1766
|
+
this._store.name = name;
|
|
1767
|
+
this._store.pushName = name;
|
|
1768
|
+
this._persistStore();
|
|
1769
|
+
}
|
|
1729
1770
|
this.emit('push_name_update', { name, remote: true });
|
|
1730
1771
|
break;
|
|
1731
1772
|
}
|
|
@@ -4546,13 +4587,71 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4546
4587
|
|
|
4547
4588
|
// Change display name
|
|
4548
4589
|
// Sends presence node with new name — immediate, no IQ needed.
|
|
4590
|
+
// The push name lives in two places, and only one of them survives.
|
|
4591
|
+
//
|
|
4592
|
+
// The presence node announces it to whoever is looking right now — it is how
|
|
4593
|
+
// a contact who has not saved the number sees a name at all. But it says
|
|
4594
|
+
// nothing lasting: the server treats it as the state of this connection, so a
|
|
4595
|
+
// name sent only that way is gone by the next login, and the account keeps
|
|
4596
|
+
// whatever the profile actually holds.
|
|
4597
|
+
//
|
|
4598
|
+
// What persists is an app state write, filed under 'setting_pushName' in the
|
|
4599
|
+
// critical_block collection. That is the record the phone and every other
|
|
4600
|
+
// linked device read, and the one the profile is served from.
|
|
4601
|
+
//
|
|
4602
|
+
// Both are sent: the presence so the change shows immediately, the patch so
|
|
4603
|
+
// it is still there tomorrow.
|
|
4604
|
+
//
|
|
4605
|
+
// Returns a promise for the patch that resolves true when it was written and
|
|
4606
|
+
// false when app state was not writable (no sync key shared yet) or the
|
|
4607
|
+
// server refused it. It never rejects, so a caller that ignores the return —
|
|
4608
|
+
// as every caller did when this only sent presence — cannot end up with an
|
|
4609
|
+
// unhandled rejection. The connection check still throws synchronously.
|
|
4549
4610
|
changeName(name) {
|
|
4550
4611
|
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
4612
|
+
// A presence node carrying only a name is a name republish: it says what
|
|
4613
|
+
// this account is called without also describing whether it is online.
|
|
4614
|
+
// Carrying a type turns it into an availability update that happens to
|
|
4615
|
+
// mention a name, which is a different thing to ask for and is not what a
|
|
4616
|
+
// rename is. The bare form goes first, on its own.
|
|
4617
|
+
this._socket.sendNode(new BinaryNode('presence', { name }, null));
|
|
4618
|
+
|
|
4619
|
+
// And the same name again on an availability node, unconditionally. The
|
|
4620
|
+
// bare form above is what a browser client sends, but the implementation
|
|
4621
|
+
// with the longest production record only ever sends this one, and ties the
|
|
4622
|
+
// server learning your name to it: announce it at least once after
|
|
4623
|
+
// connecting or other people see a placeholder instead. Making it
|
|
4624
|
+
// conditional on already being marked available would leave a session that
|
|
4625
|
+
// renames before going online with only the form that may be ignored.
|
|
4551
4626
|
this._socket.sendNode(new BinaryNode('presence', {
|
|
4552
4627
|
name,
|
|
4553
4628
|
type: 'available'
|
|
4554
4629
|
}, null));
|
|
4555
|
-
|
|
4630
|
+
|
|
4631
|
+
// `name` is the field the handshake reads when it announces this account,
|
|
4632
|
+
// and it is the one the session file carries. Writing only `pushName` left
|
|
4633
|
+
// the store's `name` at its default, so every login announced that default
|
|
4634
|
+
// again and undid the change — the account went on being called "User" no
|
|
4635
|
+
// matter how often the presence went out. Both are set: `name` because the
|
|
4636
|
+
// handshake and the file are keyed off it, `pushName` because the presence
|
|
4637
|
+
// helper reads that one first.
|
|
4638
|
+
if (this._store) {
|
|
4639
|
+
this._store.name = name;
|
|
4640
|
+
this._store.pushName = name;
|
|
4641
|
+
this._persistStore();
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
return this._appStatePatchOrFallback(AppMutations.pushNamePatch(name))
|
|
4645
|
+
.then(synced => {
|
|
4646
|
+
_whaDbg(synced
|
|
4647
|
+
? '[DBG] PUSHNAME persisted in app state: ' + name
|
|
4648
|
+
: '[DBG] PUSHNAME announced but NOT persisted — no app state sync key yet');
|
|
4649
|
+
return synced;
|
|
4650
|
+
})
|
|
4651
|
+
.catch(err => {
|
|
4652
|
+
_whaDbg('[DBG] PUSHNAME app state write failed: ' + (err && err.message));
|
|
4653
|
+
return false;
|
|
4654
|
+
});
|
|
4556
4655
|
}
|
|
4557
4656
|
|
|
4558
4657
|
// Change bio/about text
|
package/lib/Registration.js
CHANGED
|
@@ -1653,9 +1653,21 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1653
1653
|
// bites when the server keeps answering with a different reason each time.
|
|
1654
1654
|
const MAX_CODE_REQUEST_ATTEMPTS = 5;
|
|
1655
1655
|
|
|
1656
|
+
// The display name a registration is carrying, if the caller gave one. It is
|
|
1657
|
+
// never sent to the registration endpoints — those take no name, and adding one
|
|
1658
|
+
// would be inventing a field. It is written to the session, and the handshake
|
|
1659
|
+
// on the first connection is what announces it.
|
|
1660
|
+
function applyRegistrationName(store, opts) {
|
|
1661
|
+
if (!store || !opts || opts.name === undefined || opts.name === null) return;
|
|
1662
|
+
const { normalizePushName } = require('./Store');
|
|
1663
|
+
const clean = normalizePushName(opts.name);
|
|
1664
|
+
if (clean) store.name = clean;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1656
1667
|
async function requestSmsCode(store, method, opts) {
|
|
1657
1668
|
method = method || 'sms';
|
|
1658
1669
|
opts = opts || {};
|
|
1670
|
+
applyRegistrationName(store, opts);
|
|
1659
1671
|
// Before the version is read, since on Android the version comes out of the
|
|
1660
1672
|
// APK this fetches.
|
|
1661
1673
|
const _device = deviceForRegistration(store, opts);
|
|
@@ -1792,6 +1804,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1792
1804
|
|
|
1793
1805
|
async function verifyCode(store, code, opts) {
|
|
1794
1806
|
opts = opts || {};
|
|
1807
|
+
applyRegistrationName(store, opts);
|
|
1795
1808
|
// Normally already there from the code request, but a confirmation can be run
|
|
1796
1809
|
// from a fresh shell that never made one.
|
|
1797
1810
|
const _device = deviceForRegistration(store, opts);
|
package/lib/Store.js
CHANGED
|
@@ -49,7 +49,30 @@ function sign(privKey32, message) {
|
|
|
49
49
|
// Store creation
|
|
50
50
|
// ─────────────────────────────────────────────────────────
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// What is safe to announce as a display name. The server takes the value
|
|
53
|
+
// verbatim, so a stray newline or a run of spaces goes out as typed; and 'User'
|
|
54
|
+
// is the placeholder this file writes when no name was given, so accepting it
|
|
55
|
+
// back as a real name would make it indistinguishable from having none.
|
|
56
|
+
const MAX_PUSH_NAME_LENGTH = 25;
|
|
57
|
+
|
|
58
|
+
function normalizePushName(name) {
|
|
59
|
+
if (name === null || name === undefined) return null;
|
|
60
|
+
const clean = String(name).replace(/[\r\n\t]+/g, ' ').trim().replace(/\s{2,}/g, ' ');
|
|
61
|
+
if (!clean || clean === 'User') return null;
|
|
62
|
+
return clean.length > MAX_PUSH_NAME_LENGTH ? clean.slice(0, MAX_PUSH_NAME_LENGTH).trim() : clean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} phoneNumber digits, no +
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* name the display name this account announces to people who have not saved
|
|
69
|
+
* the number. Setting it here is what makes the first connection carry
|
|
70
|
+
* it: the handshake reads this field, and it only announces a name that
|
|
71
|
+
* is not the placeholder below. Left unset, the account stays nameless
|
|
72
|
+
* until something calls changeName().
|
|
73
|
+
*/
|
|
74
|
+
function createNewStore(phoneNumber, opts) {
|
|
75
|
+
opts = opts || {};
|
|
53
76
|
const noiseKeyPair = generateKeyPair();
|
|
54
77
|
const identityKeyPair = generateKeyPair();
|
|
55
78
|
const signedPreKeyPair = generateKeyPair();
|
|
@@ -89,7 +112,7 @@ function createNewStore(phoneNumber) {
|
|
|
89
112
|
backupToken,
|
|
90
113
|
registered: false,
|
|
91
114
|
codePending: false, // true after /code request, cleared on successful /register
|
|
92
|
-
name: 'User',
|
|
115
|
+
name: normalizePushName(opts.name) || 'User',
|
|
93
116
|
version,
|
|
94
117
|
device,
|
|
95
118
|
// ADVSignedDeviceIdentity bytes received from server <success> node.
|
|
@@ -307,6 +330,8 @@ function fromSixParts(sixParts) {
|
|
|
307
330
|
|
|
308
331
|
module.exports = {
|
|
309
332
|
createNewStore,
|
|
333
|
+
normalizePushName,
|
|
334
|
+
MAX_PUSH_NAME_LENGTH,
|
|
310
335
|
saveStore,
|
|
311
336
|
loadStore,
|
|
312
337
|
toSixParts,
|
package/lib/auth-utils.js
CHANGED
|
@@ -666,7 +666,9 @@ function initAuthCreds(phoneNumber, options) {
|
|
|
666
666
|
options = options || {};
|
|
667
667
|
|
|
668
668
|
// The library's own key generation — curve25519, with the right prefixes.
|
|
669
|
-
|
|
669
|
+
// options.name, when given, is the display name the account registers with:
|
|
670
|
+
// it is stored here and announced by the first handshake.
|
|
671
|
+
const store = createNewStore(phoneNumber, { name: options.name });
|
|
670
672
|
|
|
671
673
|
// ── Extra credential fields ──────────────────────────────────────────────────
|
|
672
674
|
// Not required internally, but commonly expected by application code.
|
package/lib/noise.js
CHANGED
|
@@ -301,6 +301,25 @@ function readFrameLength(data) {
|
|
|
301
301
|
return (data[0] << 16) | (data[1] << 8) | data[2];
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// ─── Handshake trace ─────────────────────────────────────────────────────────
|
|
305
|
+
//
|
|
306
|
+
// The handshake runs before there are stanzas to log, so without this the debug
|
|
307
|
+
// output jumps from "connecting" to either a secured channel or an error with
|
|
308
|
+
// nothing in between — and when it fails, nothing to say which step it got to.
|
|
309
|
+
//
|
|
310
|
+
// Only public material is rendered: ephemeral and static public keys, frame and
|
|
311
|
+
// field sizes, certificate outcome. Derived session keys and anything private
|
|
312
|
+
// are named but never printed, because a debug trace is the thing people paste
|
|
313
|
+
// into a bug report.
|
|
314
|
+
function _hsKey(buf) {
|
|
315
|
+
if (!Buffer.isBuffer(buf)) return String(buf);
|
|
316
|
+
return buf.toString('hex') + ' (' + buf.length + 'B)';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _hsSize(buf) {
|
|
320
|
+
return Buffer.isBuffer(buf) ? buf.length + 'B' : 'absent';
|
|
321
|
+
}
|
|
322
|
+
|
|
304
323
|
// ─── NoiseSocket ─────────────────────────────────────────────────────────────
|
|
305
324
|
//
|
|
306
325
|
// Events emitted:
|
|
@@ -395,9 +414,18 @@ class NoiseSocket extends EventEmitter {
|
|
|
395
414
|
};
|
|
396
415
|
this.noiseState.hash(this._ephemeralKeyPair.public);
|
|
397
416
|
|
|
417
|
+
_whaDbg('[DBG] NOISE handshake begin transport=' + this.transport +
|
|
418
|
+
' prologue=' + this._prologue.toString('hex') +
|
|
419
|
+
' pattern=Noise_XX_25519_AESGCM_SHA256');
|
|
420
|
+
_whaDbg('[DBG] NOISE ──▶ ClientHello ephemeral=' + _hsKey(this._ephemeralKeyPair.public));
|
|
421
|
+
|
|
398
422
|
const helloPayload = encodeHandshakeClientHello(this._ephemeralKeyPair.public);
|
|
399
423
|
const frame = Buffer.concat([this._prologue, makeFrame(helloPayload)]);
|
|
424
|
+
_whaDbg('[DBG] NOISE ──▶ ClientHello ' + frame.length + 'B on the wire = prologue ' +
|
|
425
|
+
this._prologue.length + 'B + header ' + FRAME_HEADER_SIZE + 'B + payload ' +
|
|
426
|
+
helloPayload.length + 'B');
|
|
400
427
|
this.socket.write(frame);
|
|
428
|
+
_whaDbg('[DBG] NOISE waiting for ServerHello');
|
|
401
429
|
}
|
|
402
430
|
|
|
403
431
|
_onData(data) {
|
|
@@ -424,16 +452,24 @@ class NoiseSocket extends EventEmitter {
|
|
|
424
452
|
|
|
425
453
|
_handleHandshakeFrame(frame) {
|
|
426
454
|
try {
|
|
455
|
+
_whaDbg('[DBG] NOISE ◀── ServerHello ' + frame.length + 'B frame');
|
|
427
456
|
const serverHello = decodeServerHello(frame);
|
|
457
|
+
_whaDbg('[DBG] NOISE ◀── ServerHello ephemeral=' + _hsSize(serverHello.ephemeral) +
|
|
458
|
+
' static=' + _hsSize(serverHello.staticEnc) + ' encrypted' +
|
|
459
|
+
' certificate=' + _hsSize(serverHello.payload) + ' encrypted');
|
|
428
460
|
if (!serverHello.ephemeral) throw new Error('No server ephemeral in ServerHello');
|
|
461
|
+
_whaDbg('[DBG] NOISE ◀── server ephemeral=' + _hsKey(serverHello.ephemeral));
|
|
429
462
|
|
|
430
463
|
this.noiseState.hash(serverHello.ephemeral);
|
|
431
464
|
const sharedEE = dhShared(this._ephemeralKeyPair.private, serverHello.ephemeral);
|
|
432
465
|
this.noiseState.mixKey(sharedEE);
|
|
466
|
+
_whaDbg('[DBG] NOISE mixed DH(our ephemeral, server ephemeral)');
|
|
433
467
|
|
|
434
468
|
const serverStaticDec = this.noiseState.decryptWithAd(serverHello.staticEnc);
|
|
469
|
+
_whaDbg('[DBG] NOISE ◀── server static key decrypted=' + _hsKey(serverStaticDec));
|
|
435
470
|
const sharedSE = dhShared(this._ephemeralKeyPair.private, serverStaticDec);
|
|
436
471
|
this.noiseState.mixKey(sharedSE);
|
|
472
|
+
_whaDbg('[DBG] NOISE mixed DH(our ephemeral, server static)');
|
|
437
473
|
|
|
438
474
|
// The server is authenticated here, before ClientFinish hands it our
|
|
439
475
|
// static key and payload. A ServerHello without a certificate leaves
|
|
@@ -442,14 +478,20 @@ class NoiseSocket extends EventEmitter {
|
|
|
442
478
|
throw new Error('WA server cert validation failed: the server sent no certificate');
|
|
443
479
|
}
|
|
444
480
|
const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
|
|
481
|
+
_whaDbg('[DBG] NOISE ◀── certificate decrypted=' + certPlain.length + 'B — verifying');
|
|
445
482
|
validateServerCert(certPlain, serverStaticDec);
|
|
483
|
+
_whaDbg('[DBG] NOISE ◀── certificate verified: signed by the root CA and ' +
|
|
484
|
+
'certifying the static key above — server authenticated');
|
|
446
485
|
|
|
447
486
|
const noisePriv = this.store.noiseKeyPair.private;
|
|
448
487
|
// Send the raw 32-byte noise public key (no 0x05 Signal prefix)
|
|
449
488
|
const noisePub = stripKeyPrefix(this.store.noiseKeyPair.public);
|
|
450
489
|
const encStatic = this.noiseState.encryptWithAd(noisePub);
|
|
490
|
+
_whaDbg('[DBG] NOISE ──▶ our static key=' + _hsKey(noisePub) +
|
|
491
|
+
' encrypted to ' + encStatic.length + 'B');
|
|
451
492
|
const sharedSS = dhShared(noisePriv, serverHello.ephemeral);
|
|
452
493
|
this.noiseState.mixKey(sharedSS);
|
|
494
|
+
_whaDbg('[DBG] NOISE mixed DH(our static, server ephemeral)');
|
|
453
495
|
|
|
454
496
|
// Web mode hands us a finished companion payload; there is no phone
|
|
455
497
|
// identity to derive anything from, and the fields below do not exist on
|
|
@@ -457,6 +499,9 @@ class NoiseSocket extends EventEmitter {
|
|
|
457
499
|
if (this._buildPayload) {
|
|
458
500
|
const encPayload = this.noiseState.encryptWithAd(this._buildPayload());
|
|
459
501
|
const finish = encodeHandshakeClientFinish(encStatic, encPayload);
|
|
502
|
+
_whaDbg('[DBG] NOISE ──▶ ClientFinish static=' + encStatic.length + 'B payload=' +
|
|
503
|
+
encPayload.length + 'B (companion) ' + (finish.length + FRAME_HEADER_SIZE) +
|
|
504
|
+
'B on the wire');
|
|
460
505
|
this.socket.write(makeFrame(finish));
|
|
461
506
|
|
|
462
507
|
const webKeys = this.noiseState.split();
|
|
@@ -465,6 +510,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
465
510
|
this.writeCounter = 0;
|
|
466
511
|
this.readCounter = 0;
|
|
467
512
|
this.secured = true;
|
|
513
|
+
_whaDbg('[DBG] NOISE split() derived the send and receive keys — channel secured');
|
|
468
514
|
|
|
469
515
|
if (this._expectSuccess) {
|
|
470
516
|
this._awaitingAuth = true;
|
|
@@ -500,7 +546,14 @@ class NoiseSocket extends EventEmitter {
|
|
|
500
546
|
const payload = encodeClientPayload({
|
|
501
547
|
username: BigInt(this.store.phoneNumber),
|
|
502
548
|
passive: false,
|
|
503
|
-
|
|
549
|
+
// A session starts out named "User" because the store needs something
|
|
550
|
+
// there, not because the account is called that. Announcing it says the
|
|
551
|
+
// account IS called "User", and the handshake runs on every connect, so
|
|
552
|
+
// a name set while online was overwritten by the placeholder the moment
|
|
553
|
+
// the connection came back. Send nothing until there is a real name —
|
|
554
|
+
// the companion payload has always guarded this the same way.
|
|
555
|
+
pushName: (this.store.registered && this.store.name &&
|
|
556
|
+
this.store.name !== 'User') ? this.store.name : null,
|
|
504
557
|
shortConnect: _reconnect,
|
|
505
558
|
// ConnectType is a closed set — 0 CELLULAR_UNKNOWN, 1 WIFI_UNKNOWN,
|
|
506
559
|
// and 100-112 for the named cellular radios. There is no 3, which is
|
|
@@ -560,6 +613,9 @@ class NoiseSocket extends EventEmitter {
|
|
|
560
613
|
|
|
561
614
|
const encPayload = this.noiseState.encryptWithAd(payload);
|
|
562
615
|
const finish = encodeHandshakeClientFinish(encStatic, encPayload);
|
|
616
|
+
_whaDbg('[DBG] NOISE ──▶ ClientFinish static=' + encStatic.length + 'B payload=' +
|
|
617
|
+
encPayload.length + 'B (username=' + this.store.phoneNumber + ' version=' +
|
|
618
|
+
_version + ') ' + (finish.length + FRAME_HEADER_SIZE) + 'B on the wire');
|
|
563
619
|
this.socket.write(makeFrame(finish));
|
|
564
620
|
|
|
565
621
|
const keys = this.noiseState.split();
|
|
@@ -567,6 +623,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
567
623
|
this.readKey = keys.readKey;
|
|
568
624
|
this.writeCounter = 0;
|
|
569
625
|
this.readCounter = 0;
|
|
626
|
+
_whaDbg('[DBG] NOISE split() derived the send and receive keys — channel secured');
|
|
627
|
+
_whaDbg('[DBG] NOISE waiting for the server to accept the payload');
|
|
570
628
|
this.secured = true;
|
|
571
629
|
|
|
572
630
|
// Wait for the server's <success> or <failure> confirmation before
|
|
@@ -575,6 +633,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
575
633
|
this._awaitingAuth = true;
|
|
576
634
|
|
|
577
635
|
} catch (err) {
|
|
636
|
+
// The step that failed is the one after the last line traced above.
|
|
637
|
+
_whaDbg('[DBG] NOISE ✗ handshake failed: ' + (err && err.message));
|
|
578
638
|
if (this._connectReject) {
|
|
579
639
|
this._connectReject(err);
|
|
580
640
|
this._connectResolve = null;
|
|
@@ -596,6 +656,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
596
656
|
if (this._awaitingAuth) {
|
|
597
657
|
this._awaitingAuth = false;
|
|
598
658
|
const tag = node && node.description;
|
|
659
|
+
_whaDbg('[DBG] NOISE ◀── <' + tag + '> — the first frame over the secured channel, ' +
|
|
660
|
+
'and the server\'s answer on the payload');
|
|
599
661
|
|
|
600
662
|
if (tag === 'success') {
|
|
601
663
|
if (this._connectResolve) {
|