whalibmob 5.10.12 → 5.11.1
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/lib/Client.js +118 -18
- package/lib/Registration.js +130 -34
- package/package.json +1 -1
package/lib/Client.js
CHANGED
|
@@ -1968,17 +1968,31 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1968
1968
|
const id = String(attrs.id || '');
|
|
1969
1969
|
const ts = parseInt(attrs.t || '0', 10);
|
|
1970
1970
|
const participant = String(attrs.participant || from);
|
|
1971
|
-
const senderPn
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1971
|
+
const senderPn = attrs.sender_pn ? String(attrs.sender_pn) : null;
|
|
1972
|
+
const senderLid = attrs.sender_lid ? String(attrs.sender_lid) : null;
|
|
1973
|
+
|
|
1974
|
+
// Learn the LID ↔ phone pairing from whichever side the stanza names.
|
|
1975
|
+
//
|
|
1976
|
+
// Decryption needs it: a message addressed by LID has to be able to find a
|
|
1977
|
+
// session filed under the phone JID, and the other way round. The pairing
|
|
1978
|
+
// is only ever visible on a stanza that carries both names, so every such
|
|
1979
|
+
// stanza is worth reading — sender_pn against a LID `from`, and sender_lid
|
|
1980
|
+
// against a phone one.
|
|
1981
|
+
const fromUser = from.split('@')[0].split(':')[0];
|
|
1982
|
+
let learnedLid = null, learnedPn = null;
|
|
1983
|
+
if (senderPn && from.endsWith('@lid')) {
|
|
1984
|
+
learnedLid = fromUser;
|
|
1985
|
+
learnedPn = senderPn.split('@')[0].split(':')[0];
|
|
1986
|
+
} else if (senderLid && !from.endsWith('@lid')) {
|
|
1987
|
+
learnedPn = fromUser;
|
|
1988
|
+
learnedLid = senderLid.split('@')[0].split(':')[0];
|
|
1989
|
+
}
|
|
1990
|
+
if (learnedLid && learnedPn && learnedLid !== learnedPn) {
|
|
1991
|
+
this._lidToPn.set(learnedLid, learnedPn);
|
|
1992
|
+
this._pnToLid.set(learnedPn, learnedLid);
|
|
1993
|
+
// Persist so the mapping survives reconnects / process restarts
|
|
1994
|
+
if (this._signal && this._signal.store && this._signal.store.setLidMapping) {
|
|
1995
|
+
this._signal.store.setLidMapping(learnedPn, learnedLid);
|
|
1982
1996
|
}
|
|
1983
1997
|
}
|
|
1984
1998
|
_whaDbg('[DBG] _handleMessage from=' + from + ' participant=' + participant + ' senderPn=' + senderPn + ' id=' + id);
|
|
@@ -2050,21 +2064,107 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2050
2064
|
this._sendReadReceipt(id, fromRaw, partRaw);
|
|
2051
2065
|
}
|
|
2052
2066
|
|
|
2067
|
+
// The other name this contact answers to, with the device kept.
|
|
2068
|
+
//
|
|
2069
|
+
// A Signal address is user plus device, so the device has to survive the
|
|
2070
|
+
// translation — 250139683860650:5@lid and 40752248147:5@s.whatsapp.net are
|
|
2071
|
+
// the same device, 40752248147:0 is a different one.
|
|
2072
|
+
//
|
|
2073
|
+
// Returns null when there is no mapping for this user yet.
|
|
2074
|
+
_counterpartJid(jid) {
|
|
2075
|
+
if (!jid) return null;
|
|
2076
|
+
const str = String(jid);
|
|
2077
|
+
const user = str.split('@')[0].split(':')[0];
|
|
2078
|
+
if (!user) return null;
|
|
2079
|
+
const colon = str.split('@')[0].indexOf(':');
|
|
2080
|
+
const device = colon >= 0 ? str.split('@')[0].slice(colon) : '';
|
|
2081
|
+
|
|
2082
|
+
if (str.endsWith('@lid')) {
|
|
2083
|
+
const pn = this._lidToPn && this._lidToPn.get(user);
|
|
2084
|
+
return pn ? pn + device + '@s.whatsapp.net' : null;
|
|
2085
|
+
}
|
|
2086
|
+
const lid = this._pnToLid && this._pnToLid.get(user);
|
|
2087
|
+
return lid ? lid + device + '@lid' : null;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Every address this message's session might be filed under, best first.
|
|
2091
|
+
_signalJidCandidates(from, participant, senderPn) {
|
|
2092
|
+
const out = [];
|
|
2093
|
+
const add = (jid) => {
|
|
2094
|
+
if (!jid) return;
|
|
2095
|
+
const str = String(jid);
|
|
2096
|
+
if (str && !out.includes(str)) out.push(str);
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// Unchanged from before: the phone JID the stanza named, or the sender.
|
|
2100
|
+
add(senderPn || ((participant && participant !== from) ? participant : from));
|
|
2101
|
+
// Then the other name for each of them.
|
|
2102
|
+
add(this._counterpartJid(out[0]));
|
|
2103
|
+
add(participant && participant !== from ? participant : null);
|
|
2104
|
+
add(from);
|
|
2105
|
+
add(this._counterpartJid(participant && participant !== from ? participant : from));
|
|
2106
|
+
|
|
2107
|
+
return out;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Try each address in turn and return the first plaintext.
|
|
2111
|
+
//
|
|
2112
|
+
// A failed attempt leaves nothing behind — libsignal either returns the
|
|
2113
|
+
// plaintext or throws, and it only commits the advanced session on success —
|
|
2114
|
+
// so trying the second address after the first misses is safe. The error
|
|
2115
|
+
// reported on total failure is the first one, which is the one about the
|
|
2116
|
+
// address the message actually named.
|
|
2117
|
+
async _decryptWithCandidates(candidates, encType, cipherBuf, id) {
|
|
2118
|
+
let firstErr = null;
|
|
2119
|
+
|
|
2120
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2121
|
+
try {
|
|
2122
|
+
const plaintext = await this._signal.decrypt(candidates[i], encType, cipherBuf);
|
|
2123
|
+
if (i > 0) {
|
|
2124
|
+
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' recovered under ' + candidates[i] +
|
|
2125
|
+
' after ' + candidates[0] + ' had no session');
|
|
2126
|
+
}
|
|
2127
|
+
return plaintext;
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (!firstErr) firstErr = err;
|
|
2130
|
+
// Only a missing session is worth asking a different address about.
|
|
2131
|
+
// A session that exists and refuses the ciphertext is a different
|
|
2132
|
+
// problem, and the answer is a retry receipt, not another lookup.
|
|
2133
|
+
if (!/no matching sessions|no session/i.test(String(err && err.message))) break;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
throw firstErr || new Error('decrypt: no address to try');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2053
2140
|
_decryptDMMessage({ node, from, participant, fromRaw, partRaw, senderPn, id, ts, dmEncNode }) {
|
|
2054
2141
|
const encType = dmEncNode.attrs && dmEncNode.attrs.type;
|
|
2055
2142
|
const cipherBuf = Buffer.isBuffer(dmEncNode.content)
|
|
2056
2143
|
? dmEncNode.content
|
|
2057
2144
|
: Buffer.from(dmEncNode.content || '');
|
|
2058
2145
|
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
|
|
2063
|
-
|
|
2146
|
+
// The same contact is reachable under two names — a phone JID and a LID —
|
|
2147
|
+
// and a Signal address keeps only the user and the device, never the
|
|
2148
|
+
// domain. So 250139683860650:5@lid and 40752248147:5@s.whatsapp.net are
|
|
2149
|
+
// two different addresses for one person, and a session filed under either
|
|
2150
|
+
// one is invisible from the other.
|
|
2151
|
+
//
|
|
2152
|
+
// Which name a message arrives under is the server's choice and it varies
|
|
2153
|
+
// between messages from the same device. When the session was built under
|
|
2154
|
+
// the phone JID and the next message comes addressed by LID, the lookup
|
|
2155
|
+
// finds nothing and decryption fails with "No matching sessions found for
|
|
2156
|
+
// message" — while the very next message from the same contact decrypts.
|
|
2157
|
+
//
|
|
2158
|
+
// The stanza's own name is tried first, so nothing that already works
|
|
2159
|
+
// changes. The counterpart follows, from the LID↔phone mapping we keep.
|
|
2160
|
+
const candidates = this._signalJidCandidates(from, participant, senderPn);
|
|
2161
|
+
const sigJid = candidates[0];
|
|
2064
2162
|
|
|
2065
|
-
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid +
|
|
2163
|
+
_whaDbg('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid +
|
|
2164
|
+
(candidates.length > 1 ? ' alts=' + candidates.slice(1).join(',') : '') +
|
|
2165
|
+
' cipherLen=' + cipherBuf.length);
|
|
2066
2166
|
|
|
2067
|
-
this.
|
|
2167
|
+
this._decryptWithCandidates(candidates, encType, cipherBuf, id)
|
|
2068
2168
|
.then(async plaintext => {
|
|
2069
2169
|
this._resolveRetry(id);
|
|
2070
2170
|
const decoded = this._decodeMsg(plaintext);
|
package/lib/Registration.js
CHANGED
|
@@ -136,51 +136,144 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
|
136
136
|
`Content-Length: ${Buffer.byteLength(body)}`
|
|
137
137
|
];
|
|
138
138
|
if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// Asked for, but not relied on: the server answers and then keeps the
|
|
140
|
+
// connection open anyway. The response is finished by its own length, not by
|
|
141
|
+
// the socket closing.
|
|
142
142
|
reqLines.push(`Connection: close`, '', body);
|
|
143
143
|
const req = reqLines.join('\r\n');
|
|
144
144
|
|
|
145
145
|
tlsSocket.write(req);
|
|
146
146
|
|
|
147
|
-
const
|
|
148
|
-
|
|
147
|
+
const { status, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
|
|
148
|
+
const bodyStr = bodyBuf.toString('utf8');
|
|
149
|
+
|
|
150
|
+
if (status !== 200) throw new Error(`HTTP ${status} ${path}: ${bodyStr}`);
|
|
151
|
+
try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A response is over when its own length says so, not when the socket shuts.
|
|
155
|
+
//
|
|
156
|
+
// This is the part `https.request` does for you and a hand-rolled socket does
|
|
157
|
+
// not. WhatsApp answers a registration request in well under a second and then
|
|
158
|
+
// leaves the connection open — `Connection: close` in the request does not
|
|
159
|
+
// change that. Waiting for end or close therefore waited for something that
|
|
160
|
+
// never came, and every proxied registration died on the read timeout with the
|
|
161
|
+
// reply already sitting in the buffer.
|
|
162
|
+
//
|
|
163
|
+
// So: parse as the bytes arrive, and stop at the end of the body — the byte
|
|
164
|
+
// count in Content-Length, or the terminating zero-length chunk. end and close
|
|
165
|
+
// stay as a fallback, for a reply that declares neither and can only be
|
|
166
|
+
// delimited by the connection itself.
|
|
167
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
168
|
+
|
|
169
|
+
function readHttpResponse(socket, label, timeoutMs) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
let raw = Buffer.alloc(0);
|
|
172
|
+
let done = false;
|
|
173
|
+
|
|
149
174
|
const timer = setTimeout(() => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
settle(reject, new Error('SOCKS proxy timed out reading ' + label));
|
|
176
|
+
}, timeoutMs || 30000);
|
|
177
|
+
|
|
178
|
+
function settle(fn, arg) {
|
|
179
|
+
if (done) return;
|
|
180
|
+
done = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
socket.removeListener('data', onData);
|
|
183
|
+
socket.removeListener('end', onEnd);
|
|
184
|
+
socket.removeListener('close', onEnd);
|
|
185
|
+
socket.removeListener('error', onError);
|
|
186
|
+
socket.destroy();
|
|
187
|
+
fn(arg);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onData(chunk) {
|
|
191
|
+
raw = raw.length ? Buffer.concat([raw, chunk]) : chunk;
|
|
192
|
+
if (raw.length > MAX_RESPONSE_BYTES) {
|
|
193
|
+
return settle(reject, new Error('response too large reading ' + label));
|
|
194
|
+
}
|
|
195
|
+
const parsed = parseHttpResponse(raw, false);
|
|
196
|
+
if (parsed) settle(resolve, parsed);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The connection did close after all — then whatever arrived is the whole
|
|
200
|
+
// reply, even without a declared length.
|
|
201
|
+
function onEnd() {
|
|
202
|
+
const parsed = parseHttpResponse(raw, true);
|
|
203
|
+
if (parsed) return settle(resolve, parsed);
|
|
204
|
+
settle(reject, new Error('connection closed mid-response reading ' + label));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function onError(err) { settle(reject, err); }
|
|
208
|
+
|
|
209
|
+
socket.on('data', onData);
|
|
210
|
+
socket.on('end', onEnd);
|
|
211
|
+
socket.on('close', onEnd);
|
|
212
|
+
socket.on('error', onError);
|
|
158
213
|
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse what has arrived so far.
|
|
218
|
+
*
|
|
219
|
+
* Returns { status, headers, body } once the response is complete, or null
|
|
220
|
+
* while it is still short. With `atEof` the caller is telling us no more bytes
|
|
221
|
+
* are coming, which is itself a valid way for a body to end.
|
|
222
|
+
*/
|
|
223
|
+
function parseHttpResponse(raw, atEof) {
|
|
224
|
+
const headerEnd = raw.indexOf('\r\n\r\n');
|
|
225
|
+
if (headerEnd < 0) return null;
|
|
226
|
+
|
|
227
|
+
// Headers are latin1 by definition; only the body may be UTF-8.
|
|
228
|
+
const headers = raw.slice(0, headerEnd).toString('latin1');
|
|
229
|
+
const status = parseInt(headers.split(' ')[1], 10);
|
|
230
|
+
const bodyStart = headerEnd + 4;
|
|
231
|
+
|
|
232
|
+
if (/^transfer-encoding:[ \t]*chunked/im.test(headers)) {
|
|
233
|
+
const decoded = decodeChunkedBody(raw.slice(bodyStart));
|
|
234
|
+
if (decoded) return { status, headers, body: decoded };
|
|
235
|
+
return atEof ? { status, headers, body: Buffer.alloc(0) } : null;
|
|
236
|
+
}
|
|
159
237
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (/transfer-encoding:\s*chunked/i.test(headerPart)) {
|
|
169
|
-
let decoded = '';
|
|
170
|
-
let pos = 0;
|
|
171
|
-
while (pos < bodyStr.length) {
|
|
172
|
-
const lineEnd = bodyStr.indexOf('\r\n', pos);
|
|
173
|
-
if (lineEnd === -1) break;
|
|
174
|
-
const chunkSize = parseInt(bodyStr.slice(pos, lineEnd), 16);
|
|
175
|
-
if (!chunkSize) break;
|
|
176
|
-
decoded += bodyStr.slice(lineEnd + 2, lineEnd + 2 + chunkSize);
|
|
177
|
-
pos = lineEnd + 2 + chunkSize + 2;
|
|
238
|
+
const match = /^content-length:[ \t]*(\d+)/im.exec(headers);
|
|
239
|
+
if (match) {
|
|
240
|
+
const length = parseInt(match[1], 10);
|
|
241
|
+
// Counted in bytes, which is why this works on the buffer and not on a
|
|
242
|
+
// decoded string — one accented character in an error message is two bytes
|
|
243
|
+
// and would leave the check one short forever.
|
|
244
|
+
if (raw.length - bodyStart >= length) {
|
|
245
|
+
return { status, headers, body: raw.slice(bodyStart, bodyStart + length) };
|
|
178
246
|
}
|
|
179
|
-
|
|
247
|
+
return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
|
|
183
|
-
|
|
250
|
+
// These two carry no body at all, whatever else the headers say.
|
|
251
|
+
if (status === 204 || status === 304) {
|
|
252
|
+
return { status, headers, body: Buffer.alloc(0) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// No length anywhere: the body runs to the end of the connection.
|
|
256
|
+
return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Decode a chunked body, or null while the terminating chunk is still missing. */
|
|
260
|
+
function decodeChunkedBody(buf) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
let pos = 0;
|
|
263
|
+
|
|
264
|
+
for (;;) {
|
|
265
|
+
const lineEnd = buf.indexOf('\r\n', pos);
|
|
266
|
+
if (lineEnd < 0) return null;
|
|
267
|
+
// A size line may carry extensions after a semicolon; parseInt stops there.
|
|
268
|
+
const size = parseInt(buf.slice(pos, lineEnd).toString('latin1'), 16);
|
|
269
|
+
if (!Number.isFinite(size) || size < 0) return null;
|
|
270
|
+
if (size === 0) return Buffer.concat(parts); // the terminating chunk
|
|
271
|
+
const start = lineEnd + 2;
|
|
272
|
+
const end = start + size;
|
|
273
|
+
if (buf.length < end + 2) return null; // chunk still arriving
|
|
274
|
+
parts.push(buf.slice(start, end));
|
|
275
|
+
pos = end + 2;
|
|
276
|
+
}
|
|
184
277
|
}
|
|
185
278
|
const {
|
|
186
279
|
REGISTRATION_ENDPOINT,
|
|
@@ -953,3 +1046,6 @@ async function verifyCode(store, code) {
|
|
|
953
1046
|
}
|
|
954
1047
|
|
|
955
1048
|
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
1049
|
+
|
|
1050
|
+
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1051
|
+
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|