whalibmob 5.32.2 → 5.33.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 -7
- package/README.md +32 -7
- package/cli.js +12 -10
- package/index.d.ts +6 -0
- package/index.js +9 -2
- package/lib/PushClient.js +36 -12
- package/lib/Registration.js +4 -4
- package/lib/Store.js +9 -0
- package/lib/apns-courier.js +625 -0
- package/lib/apns.js +642 -0
- package/lib/plist.js +451 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─── The APNs courier stream ──────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// What MCS is to an Android install, the courier is to an iPhone: one TLS
|
|
6
|
+
// connection to Apple that stays open for as long as the app is alive, over
|
|
7
|
+
// which every push arrives. WhatsApp's silent verification push is one of them,
|
|
8
|
+
// so a library that wants to read that code has to speak the same stream.
|
|
9
|
+
//
|
|
10
|
+
// The shape of it:
|
|
11
|
+
//
|
|
12
|
+
// 1. /bag names the courier pool: a hostname and how many hosts are
|
|
13
|
+
// in it. A device picks one at random and dials
|
|
14
|
+
// "<n>-<hostname>:443".
|
|
15
|
+
// 2. TLS with ALPN "apns-security-v3". The protocol name matters —
|
|
16
|
+
// it is what tells Apple the credentials ride in the first
|
|
17
|
+
// frame rather than in a TLS client certificate, which is
|
|
18
|
+
// how the older revision worked.
|
|
19
|
+
// 3. CONNECT/READY the device presents its activation certificate and a
|
|
20
|
+
// signature over a fresh nonce; Apple answers with the
|
|
21
|
+
// device token that identifies it from here on.
|
|
22
|
+
// 4. FILTER the topics (iOS bundle ids) this connection wants pushes
|
|
23
|
+
// for, as SHA-1 hashes.
|
|
24
|
+
// 5. GET_TOKEN the per-topic token. This — not the device token — is
|
|
25
|
+
// what an app hands its server, and what goes into
|
|
26
|
+
// WhatsApp's registration body as push_token.
|
|
27
|
+
//
|
|
28
|
+
// Frames are a byte tag, a four-byte big-endian length, and a payload of
|
|
29
|
+
// fields. A field is a byte id, a two-byte big-endian length, and its bytes.
|
|
30
|
+
// A field id may repeat within one frame: that is how FILTER carries several
|
|
31
|
+
// topics, so fields are kept as a list of pairs on the way out and as a map of
|
|
32
|
+
// arrays on the way in. Collapsing them into a plain object would silently drop
|
|
33
|
+
// every topic but the last.
|
|
34
|
+
|
|
35
|
+
const tls = require('tls');
|
|
36
|
+
const https = require('https');
|
|
37
|
+
const http = require('http');
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
|
|
40
|
+
const { dbg: _whaDbg } = require('./logger');
|
|
41
|
+
const { proxyAgent, socksProxyUrl, socksConnect } = require('./socks');
|
|
42
|
+
const plist = require('./plist');
|
|
43
|
+
|
|
44
|
+
const BAG_HOST = 'init-p01st.push.apple.com';
|
|
45
|
+
const BAG_PATH = '/bag';
|
|
46
|
+
const COURIER_PORT = 443;
|
|
47
|
+
const COURIER_ALPN = 'apns-security-v3';
|
|
48
|
+
|
|
49
|
+
// The pool as it has been for years. Only used when /bag cannot be reached: a
|
|
50
|
+
// courier that answers is better than no push line at all, and these are the
|
|
51
|
+
// same values the bag hands back.
|
|
52
|
+
const DEFAULT_BAG = { hostCount: 50, hostname: 'courier.push.apple.com' };
|
|
53
|
+
|
|
54
|
+
const HTTP_TIMEOUT_MS = 15000;
|
|
55
|
+
const CONNECT_TIMEOUT_MS = 20000;
|
|
56
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
57
|
+
// Far below Apple's idle cutoff, and far above a heartbeat that would look like
|
|
58
|
+
// a client stuck in a loop. A verification listen lasts minutes, not hours.
|
|
59
|
+
const KEEP_ALIVE_MS = 30000;
|
|
60
|
+
|
|
61
|
+
// ─── Frame tags ───────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const TAG = {
|
|
64
|
+
CONNECT: 0x07,
|
|
65
|
+
READY: 0x08,
|
|
66
|
+
FILTER: 0x09,
|
|
67
|
+
NOTIFICATION: 0x0a,
|
|
68
|
+
ACK: 0x0b,
|
|
69
|
+
KEEP_ALIVE: 0x0c,
|
|
70
|
+
KEEP_ALIVE_ACK: 0x0d,
|
|
71
|
+
NO_STORAGE: 0x0e,
|
|
72
|
+
GET_TOKEN: 0x11,
|
|
73
|
+
TOKEN_RESPONSE: 0x12,
|
|
74
|
+
STATE: 0x14
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Field ids, named per frame because the same number means different things in
|
|
78
|
+
// different frames — 0x03 is the device token in READY and the payload in a
|
|
79
|
+
// NOTIFICATION.
|
|
80
|
+
const F_CONNECT_TOKEN = 0x01;
|
|
81
|
+
const F_CONNECT_STATE = 0x02;
|
|
82
|
+
const F_CONNECT_FLAGS = 0x05;
|
|
83
|
+
const F_CONNECT_CERT = 0x0c;
|
|
84
|
+
const F_CONNECT_NONCE = 0x0d;
|
|
85
|
+
const F_CONNECT_SIGNATURE = 0x0e;
|
|
86
|
+
|
|
87
|
+
const F_READY_STATUS = 0x01;
|
|
88
|
+
const F_READY_TOKEN = 0x03;
|
|
89
|
+
|
|
90
|
+
const F_NOTIFICATION_TOPIC = 0x01;
|
|
91
|
+
const F_NOTIFICATION_PAYLOAD = 0x03;
|
|
92
|
+
const F_NOTIFICATION_ID = 0x04;
|
|
93
|
+
|
|
94
|
+
const F_ACK_TOKEN = 0x01;
|
|
95
|
+
const F_ACK_ID = 0x04;
|
|
96
|
+
const F_ACK_STATUS = 0x08;
|
|
97
|
+
|
|
98
|
+
const F_GET_TOKEN_TOKEN = 0x01;
|
|
99
|
+
const F_GET_TOKEN_TOPIC = 0x02;
|
|
100
|
+
const F_GET_TOKEN_PAD = 0x03;
|
|
101
|
+
|
|
102
|
+
const F_TOKEN_RESPONSE_TOKEN = 0x02;
|
|
103
|
+
const F_TOKEN_RESPONSE_TOPIC = 0x03;
|
|
104
|
+
|
|
105
|
+
// CONNECT flags, as a real client sends them. Opaque on the wire; kept as the
|
|
106
|
+
// literal four bytes rather than dressed up as a bitfield nobody can check.
|
|
107
|
+
const CONNECT_FLAGS = Buffer.from([0x00, 0x00, 0x00, 0x41]);
|
|
108
|
+
const CONNECT_STATE = Buffer.from([0x01]);
|
|
109
|
+
|
|
110
|
+
// ─── Framing ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Encode one frame.
|
|
114
|
+
*
|
|
115
|
+
* @param {number} tag the frame tag
|
|
116
|
+
* @param {Array<[number, Buffer]>} fields id/value pairs, in wire order; an id
|
|
117
|
+
* may appear more than once
|
|
118
|
+
* @returns {Buffer}
|
|
119
|
+
*/
|
|
120
|
+
function encodeFrame(tag, fields) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
let payloadLength = 0;
|
|
123
|
+
for (const [id, value] of fields) {
|
|
124
|
+
if (value == null) continue;
|
|
125
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
126
|
+
const header = Buffer.alloc(3);
|
|
127
|
+
header[0] = id & 0xff;
|
|
128
|
+
header.writeUInt16BE(buf.length, 1);
|
|
129
|
+
parts.push(header, buf);
|
|
130
|
+
payloadLength += 3 + buf.length;
|
|
131
|
+
}
|
|
132
|
+
const head = Buffer.alloc(5);
|
|
133
|
+
head[0] = tag & 0xff;
|
|
134
|
+
head.writeUInt32BE(payloadLength, 1);
|
|
135
|
+
return Buffer.concat([head, ...parts], 5 + payloadLength);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decode a frame payload into its fields.
|
|
140
|
+
*
|
|
141
|
+
* Repeated ids accumulate, so `fields.get(id)` is always an array. `first` is
|
|
142
|
+
* the reader for the ids that only ever appear once.
|
|
143
|
+
*
|
|
144
|
+
* @param {Buffer} payload
|
|
145
|
+
* @returns {Map<number, Buffer[]>}
|
|
146
|
+
*/
|
|
147
|
+
function decodeFields(payload) {
|
|
148
|
+
const fields = new Map();
|
|
149
|
+
let offset = 0;
|
|
150
|
+
while (offset + 3 <= payload.length) {
|
|
151
|
+
const id = payload[offset];
|
|
152
|
+
const length = payload.readUInt16BE(offset + 1);
|
|
153
|
+
if (offset + 3 + length > payload.length) break;
|
|
154
|
+
const value = payload.subarray(offset + 3, offset + 3 + length);
|
|
155
|
+
if (fields.has(id)) fields.get(id).push(value);
|
|
156
|
+
else fields.set(id, [value]);
|
|
157
|
+
offset += 3 + length;
|
|
158
|
+
}
|
|
159
|
+
return fields;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function first(fields, id) {
|
|
163
|
+
const values = fields.get(id);
|
|
164
|
+
return values && values.length ? values[0] : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── The bag ──────────────────────────────────────────────────────────────────
|
|
168
|
+
//
|
|
169
|
+
// Served signed rather than encrypted, which is why plain HTTP is the canonical
|
|
170
|
+
// URL. HTTPS is tried first anyway — it works, it is what a proxied session can
|
|
171
|
+
// route, and it costs one attempt. Every failure ends at DEFAULT_BAG.
|
|
172
|
+
|
|
173
|
+
function httpGet(url) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const u = new URL(url);
|
|
176
|
+
const isTls = u.protocol === 'https:';
|
|
177
|
+
const transport = isTls ? https : http;
|
|
178
|
+
const agent = isTls ? proxyAgent() : null;
|
|
179
|
+
|
|
180
|
+
const req = transport.request({
|
|
181
|
+
hostname: u.hostname,
|
|
182
|
+
port: u.port || (isTls ? 443 : 80),
|
|
183
|
+
path: u.pathname + u.search,
|
|
184
|
+
method: 'GET',
|
|
185
|
+
headers: { 'User-Agent': 'com.apple.ist.ds.appleconnect.web/1.0' },
|
|
186
|
+
agent: agent || undefined
|
|
187
|
+
}, res => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
res.on('data', c => chunks.push(c));
|
|
190
|
+
res.on('end', () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks) }));
|
|
191
|
+
res.on('error', reject);
|
|
192
|
+
});
|
|
193
|
+
req.on('error', reject);
|
|
194
|
+
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error('timed out')));
|
|
195
|
+
req.end();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseBag(body) {
|
|
200
|
+
const outer = plist.parse(body);
|
|
201
|
+
const inner = plist.parse(outer.bag);
|
|
202
|
+
const hostCount = Number(inner.APNSCourierHostcount);
|
|
203
|
+
const hostname = String(inner.APNSCourierHostname || '');
|
|
204
|
+
if (!hostname || !Number.isFinite(hostCount) || hostCount < 1) {
|
|
205
|
+
throw new Error('bag has no courier pool');
|
|
206
|
+
}
|
|
207
|
+
return { hostCount, hostname };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The courier pool, or the long-standing default when Apple cannot be asked.
|
|
212
|
+
*
|
|
213
|
+
* @returns {Promise<{hostCount: number, hostname: string}>}
|
|
214
|
+
*/
|
|
215
|
+
/**
|
|
216
|
+
* Where to ask for the bag, in the order to try.
|
|
217
|
+
*
|
|
218
|
+
* HTTPS first because that is the one a proxied session can route: the SOCKS
|
|
219
|
+
* agent is an https.Agent, so a plain-HTTP attempt would go out direct and
|
|
220
|
+
* around the proxy the user asked for. When one is configured the fallback is
|
|
221
|
+
* dropped rather than leaked — the default pool is the better answer.
|
|
222
|
+
*
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
function bagUrls() {
|
|
226
|
+
const urls = ['https://' + BAG_HOST + BAG_PATH];
|
|
227
|
+
if (!socksProxyUrl()) urls.push('http://' + BAG_HOST + BAG_PATH);
|
|
228
|
+
return urls;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function fetchBag() {
|
|
232
|
+
for (const url of bagUrls()) {
|
|
233
|
+
try {
|
|
234
|
+
const res = await httpGet(url);
|
|
235
|
+
if (res.status < 200 || res.status >= 300) throw new Error('HTTP ' + res.status);
|
|
236
|
+
const bag = parseBag(res.body);
|
|
237
|
+
_whaDbg('[DBG] APNs bag: ' + bag.hostCount + ' hosts at ' + bag.hostname);
|
|
238
|
+
return bag;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
_whaDbg('[DBG] APNs bag fetch failed (' + url + '): ' + (err && err.message));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
_whaDbg('[DBG] APNs bag unavailable — using the default courier pool');
|
|
244
|
+
return Object.assign({}, DEFAULT_BAG);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Connection credentials ───────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** SHA-1 of a topic name — how the wire names an iOS bundle id. */
|
|
250
|
+
function topicHash(topic) {
|
|
251
|
+
return crypto.createHash('sha1').update(String(topic), 'utf8').digest();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 17 bytes: a zero, the current time in milliseconds, and eight random ones.
|
|
255
|
+
// Apple checks the timestamp is recent, which is what stops a captured
|
|
256
|
+
// signature from being replayed.
|
|
257
|
+
function createNonce() {
|
|
258
|
+
const nonce = Buffer.alloc(17);
|
|
259
|
+
nonce.writeBigUInt64BE(BigInt(Date.now()), 1);
|
|
260
|
+
crypto.randomBytes(8).copy(nonce, 9);
|
|
261
|
+
return nonce;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The signature is prefixed with a two-byte version tag. Apple rejects the bare
|
|
265
|
+
// signature, so the tag is part of the field rather than framing around it.
|
|
266
|
+
const NONCE_SIGNATURE_TAG = Buffer.from([0x01, 0x01]);
|
|
267
|
+
|
|
268
|
+
function signNonce(privateKeyDer, nonce) {
|
|
269
|
+
const key = crypto.createPrivateKey({ key: privateKeyDer, format: 'der', type: 'pkcs8' });
|
|
270
|
+
return Buffer.concat([NONCE_SIGNATURE_TAG, crypto.sign('sha1', nonce, key)]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apple hands the activation certificate back in whatever encoding it pleases
|
|
274
|
+
// (the activation record carries PEM); the courier wants DER. Round-tripping it
|
|
275
|
+
// through X509Certificate normalises both cases and rejects a corrupt one here,
|
|
276
|
+
// where the error still says what it is.
|
|
277
|
+
function certificateDer(certificate) {
|
|
278
|
+
return new crypto.X509Certificate(certificate).raw;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── The connection ───────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
class ApnsCourierConnection {
|
|
284
|
+
/**
|
|
285
|
+
* @param {object} session { privateKeyDer, publicKeyDer, deviceCertificate, deviceToken? }
|
|
286
|
+
* all Buffers; deviceToken is the one from a previous
|
|
287
|
+
* connection, re-presented so Apple issues the same
|
|
288
|
+
* one again
|
|
289
|
+
* @param {object} [opts] { topics, onNotification, onLost }
|
|
290
|
+
*/
|
|
291
|
+
constructor(session, opts) {
|
|
292
|
+
opts = opts || {};
|
|
293
|
+
this.session = session;
|
|
294
|
+
this.topics = Array.isArray(opts.topics) ? opts.topics.slice() : [];
|
|
295
|
+
this.onNotification = typeof opts.onNotification === 'function' ? opts.onNotification : null;
|
|
296
|
+
this.onLost = typeof opts.onLost === 'function' ? opts.onLost : null;
|
|
297
|
+
|
|
298
|
+
this.socket = null;
|
|
299
|
+
this.deviceToken = session.deviceToken || null;
|
|
300
|
+
// Set by the handshake when Apple hands back a device token other than the
|
|
301
|
+
// one presented. False until then, including before any connection.
|
|
302
|
+
this.tokenReissued = false;
|
|
303
|
+
this.closed = false;
|
|
304
|
+
this.buffer = Buffer.alloc(0);
|
|
305
|
+
this.pending = [];
|
|
306
|
+
this.keepAliveTimer = null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── lifecycle ──
|
|
310
|
+
|
|
311
|
+
async connect() {
|
|
312
|
+
const bag = await fetchBag();
|
|
313
|
+
// Host 0 does not exist; the pool is 1..hostCount.
|
|
314
|
+
const index = 1 + Math.floor(Math.random() * Math.max(1, bag.hostCount - 1));
|
|
315
|
+
const host = index + '-' + bag.hostname;
|
|
316
|
+
|
|
317
|
+
_whaDbg('[DBG] APNs courier dialling ' + host);
|
|
318
|
+
this.socket = await this._dial(host);
|
|
319
|
+
this.socket.on('data', chunk => this._onData(chunk));
|
|
320
|
+
this.socket.on('error', err => this._fail(err));
|
|
321
|
+
this.socket.on('close', () => this._fail(new Error('courier connection closed')));
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await this._handshake();
|
|
325
|
+
if (this.topics.length) this._sendFilter();
|
|
326
|
+
this._startKeepAlive();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
// A handshake that did not finish leaves a socket nobody will ever read
|
|
329
|
+
// from. Callers do close on failure, but the connection should not depend
|
|
330
|
+
// on them remembering to.
|
|
331
|
+
this.close();
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async _dial(host) {
|
|
338
|
+
const proxyUrl = socksProxyUrl();
|
|
339
|
+
const base = proxyUrl
|
|
340
|
+
? await socksConnect(proxyUrl, host, COURIER_PORT, CONNECT_TIMEOUT_MS)
|
|
341
|
+
: null;
|
|
342
|
+
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const options = {
|
|
345
|
+
host,
|
|
346
|
+
port: COURIER_PORT,
|
|
347
|
+
servername: host,
|
|
348
|
+
ALPNProtocols: [COURIER_ALPN],
|
|
349
|
+
// The courier answers with a certificate from an Apple-internal issuer
|
|
350
|
+
// that is in no public trust store, so chain validation cannot succeed
|
|
351
|
+
// here and is not what protects this connection: the device proves
|
|
352
|
+
// itself with a signature over a fresh nonce, and every push that
|
|
353
|
+
// matters is a WhatsApp payload the registration server signs for
|
|
354
|
+
// separately. Verification stays on for every other socket in the
|
|
355
|
+
// library — this is the one endpoint that cannot use it.
|
|
356
|
+
rejectUnauthorized: false
|
|
357
|
+
};
|
|
358
|
+
if (base) options.socket = base;
|
|
359
|
+
|
|
360
|
+
const socket = tls.connect(options, () => {
|
|
361
|
+
socket.setTimeout(0);
|
|
362
|
+
socket.setNoDelay(true);
|
|
363
|
+
// The courier speaks its own protocol over the TLS session, and says so
|
|
364
|
+
// through ALPN. A middlebox that terminates TLS — a corporate gateway,
|
|
365
|
+
// an inspecting proxy — negotiates no protocol at all and then drops
|
|
366
|
+
// the connection the moment a frame that is not HTTP goes out. Saying
|
|
367
|
+
// that here turns an unexplained disconnect into the one line that
|
|
368
|
+
// names the cause.
|
|
369
|
+
if (socket.alpnProtocol !== COURIER_ALPN) {
|
|
370
|
+
socket.destroy();
|
|
371
|
+
reject(new Error('courier did not negotiate ' + COURIER_ALPN +
|
|
372
|
+
' (TLS is being intercepted on this network)'));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
resolve(socket);
|
|
376
|
+
});
|
|
377
|
+
socket.setTimeout(CONNECT_TIMEOUT_MS, () => socket.destroy(new Error('courier connect timed out')));
|
|
378
|
+
socket.once('error', reject);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async _handshake() {
|
|
383
|
+
const nonce = createNonce();
|
|
384
|
+
const fields = [
|
|
385
|
+
[F_CONNECT_STATE, CONNECT_STATE],
|
|
386
|
+
[F_CONNECT_FLAGS, CONNECT_FLAGS],
|
|
387
|
+
[F_CONNECT_CERT, certificateDer(this.session.deviceCertificate)],
|
|
388
|
+
[F_CONNECT_NONCE, nonce],
|
|
389
|
+
[F_CONNECT_SIGNATURE, signNonce(this.session.privateKeyDer, nonce)]
|
|
390
|
+
];
|
|
391
|
+
// Presenting the previous device token asks Apple for the same one back.
|
|
392
|
+
// It has to be the same one: the per-topic push token already handed to
|
|
393
|
+
// WhatsApp is derived from it, and a new device token would leave that
|
|
394
|
+
// push_token addressing a connection nobody is listening on.
|
|
395
|
+
if (this.deviceToken) fields.unshift([F_CONNECT_TOKEN, this.deviceToken]);
|
|
396
|
+
|
|
397
|
+
const ready = await this._exchange(TAG.CONNECT, fields,
|
|
398
|
+
packet => packet.tag === TAG.READY);
|
|
399
|
+
|
|
400
|
+
const status = first(ready.fields, F_READY_STATUS);
|
|
401
|
+
if (!status || status.length === 0 || status[0] !== 0) {
|
|
402
|
+
throw new Error('courier refused CONNECT: status=' +
|
|
403
|
+
(status && status.length ? status[0] : 'none'));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const token = first(ready.fields, F_READY_TOKEN);
|
|
407
|
+
if (!token) throw new Error('courier READY carried no device token');
|
|
408
|
+
|
|
409
|
+
// A reissued device token invalidates every per-topic token derived from
|
|
410
|
+
// the old one, including the push_token WhatsApp may already hold. Say so,
|
|
411
|
+
// so the caller can drop what it cached rather than keep sending an address
|
|
412
|
+
// nothing answers on.
|
|
413
|
+
this.tokenReissued = Boolean(this.deviceToken && !this.deviceToken.equals(token));
|
|
414
|
+
if (this.tokenReissued) {
|
|
415
|
+
_whaDbg('[DBG] APNs device token was reissued — per-topic tokens have to be refetched');
|
|
416
|
+
}
|
|
417
|
+
this.deviceToken = Buffer.from(token);
|
|
418
|
+
_whaDbg('[DBG] APNs courier connected');
|
|
419
|
+
|
|
420
|
+
// Says the connection wants stored pushes delivered now. Without it a push
|
|
421
|
+
// that arrived while the device was offline stays parked at Apple.
|
|
422
|
+
this._send(TAG.STATE, [
|
|
423
|
+
[0x01, Buffer.from([0x01])],
|
|
424
|
+
[0x02, Buffer.from([0x7f, 0xff, 0xff, 0xff])]
|
|
425
|
+
]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
close() {
|
|
429
|
+
if (this.closed) return;
|
|
430
|
+
this.closed = true;
|
|
431
|
+
if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
|
|
432
|
+
this.keepAliveTimer = null;
|
|
433
|
+
const socket = this.socket;
|
|
434
|
+
this.socket = null;
|
|
435
|
+
if (socket) { try { socket.destroy(); } catch (_) {} }
|
|
436
|
+
this._rejectPending(new Error('courier closed'));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── requests ──
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The push token for one topic, hex-encoded — the value a WhatsApp
|
|
443
|
+
* registration carries as push_token.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} topic an iOS bundle id, e.g. net.whatsapp.WhatsApp
|
|
446
|
+
* @returns {Promise<string>}
|
|
447
|
+
*/
|
|
448
|
+
async requestToken(topic) {
|
|
449
|
+
const hash = topicHash(topic);
|
|
450
|
+
const packet = await this._exchange(TAG.GET_TOKEN, [
|
|
451
|
+
[F_GET_TOKEN_TOKEN, this.deviceToken],
|
|
452
|
+
[F_GET_TOKEN_TOPIC, hash],
|
|
453
|
+
[F_GET_TOKEN_PAD, Buffer.from([0x00, 0x00])]
|
|
454
|
+
], p => p.tag === TAG.TOKEN_RESPONSE &&
|
|
455
|
+
(!first(p.fields, F_TOKEN_RESPONSE_TOPIC) ||
|
|
456
|
+
first(p.fields, F_TOKEN_RESPONSE_TOPIC).equals(hash)));
|
|
457
|
+
|
|
458
|
+
const token = first(packet.fields, F_TOKEN_RESPONSE_TOKEN);
|
|
459
|
+
if (!token) throw new Error('courier TOKEN_RESPONSE carried no token');
|
|
460
|
+
return token.toString('hex');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// FILTER is the subscription: one field 0x02 per topic, all in one frame.
|
|
464
|
+
_sendFilter() {
|
|
465
|
+
const fields = [[0x01, this.deviceToken]];
|
|
466
|
+
for (const topic of this.topics) fields.push([0x02, topicHash(topic)]);
|
|
467
|
+
this._send(TAG.FILTER, fields);
|
|
468
|
+
_whaDbg('[DBG] APNs subscribed to ' + this.topics.join(', '));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_startKeepAlive() {
|
|
472
|
+
this.keepAliveTimer = setInterval(() => {
|
|
473
|
+
if (this.closed) return;
|
|
474
|
+
try { this._send(TAG.KEEP_ALIVE, []); } catch (_) {}
|
|
475
|
+
}, KEEP_ALIVE_MS);
|
|
476
|
+
if (this.keepAliveTimer.unref) this.keepAliveTimer.unref();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── wire ──
|
|
480
|
+
|
|
481
|
+
_send(tag, fields) {
|
|
482
|
+
if (!this.socket) throw new Error('courier is not connected');
|
|
483
|
+
this.socket.write(encodeFrame(tag, fields));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_exchange(tag, fields, match) {
|
|
487
|
+
return new Promise((resolve, reject) => {
|
|
488
|
+
const entry = {
|
|
489
|
+
match,
|
|
490
|
+
resolve,
|
|
491
|
+
reject,
|
|
492
|
+
timer: setTimeout(() => {
|
|
493
|
+
this._drop(entry);
|
|
494
|
+
reject(new Error('courier request timed out'));
|
|
495
|
+
}, REQUEST_TIMEOUT_MS)
|
|
496
|
+
};
|
|
497
|
+
if (entry.timer.unref) entry.timer.unref();
|
|
498
|
+
this.pending.push(entry);
|
|
499
|
+
try {
|
|
500
|
+
this._send(tag, fields);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
this._drop(entry);
|
|
503
|
+
clearTimeout(entry.timer);
|
|
504
|
+
reject(err);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
_drop(entry) {
|
|
510
|
+
const index = this.pending.indexOf(entry);
|
|
511
|
+
if (index >= 0) this.pending.splice(index, 1);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_rejectPending(err) {
|
|
515
|
+
const waiting = this.pending.splice(0, this.pending.length);
|
|
516
|
+
for (const entry of waiting) {
|
|
517
|
+
clearTimeout(entry.timer);
|
|
518
|
+
entry.reject(err);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
_fail(err) {
|
|
523
|
+
if (this.closed) return;
|
|
524
|
+
this.closed = true;
|
|
525
|
+
if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
|
|
526
|
+
this.keepAliveTimer = null;
|
|
527
|
+
_whaDbg('[DBG] APNs courier lost: ' + (err && err.message));
|
|
528
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
529
|
+
this._rejectPending(error);
|
|
530
|
+
// A listener waiting on a push has no other way to learn the line is gone,
|
|
531
|
+
// and waiting out a three-minute timeout on a dead socket helps nobody.
|
|
532
|
+
if (this.onLost) {
|
|
533
|
+
try { this.onLost(error); } catch (_) {}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_onData(chunk) {
|
|
538
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
539
|
+
for (;;) {
|
|
540
|
+
if (this.buffer.length < 5) return;
|
|
541
|
+
const length = this.buffer.readUInt32BE(1);
|
|
542
|
+
if (this.buffer.length < 5 + length) return;
|
|
543
|
+
const tag = this.buffer[0];
|
|
544
|
+
const payload = this.buffer.subarray(5, 5 + length);
|
|
545
|
+
this.buffer = this.buffer.subarray(5 + length);
|
|
546
|
+
this._dispatch({ tag, fields: decodeFields(payload) });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
_dispatch(packet) {
|
|
551
|
+
if (packet.tag === TAG.NOTIFICATION) {
|
|
552
|
+
this._acknowledge(packet);
|
|
553
|
+
if (this.onNotification) {
|
|
554
|
+
try { this.onNotification(packet); } catch (_) {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const entry of this.pending) {
|
|
558
|
+
let hit = false;
|
|
559
|
+
try { hit = entry.match(packet); } catch (_) { hit = false; }
|
|
560
|
+
if (hit) {
|
|
561
|
+
this._drop(entry);
|
|
562
|
+
clearTimeout(entry.timer);
|
|
563
|
+
entry.resolve(packet);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// An unacknowledged push is redelivered on every reconnect, so this is not
|
|
570
|
+
// politeness — it is what stops the same notification arriving forever.
|
|
571
|
+
_acknowledge(packet) {
|
|
572
|
+
const id = first(packet.fields, F_NOTIFICATION_ID);
|
|
573
|
+
if (!id) return;
|
|
574
|
+
try {
|
|
575
|
+
this._send(TAG.ACK, [
|
|
576
|
+
[F_ACK_TOKEN, this.deviceToken],
|
|
577
|
+
[F_ACK_ID, id],
|
|
578
|
+
[F_ACK_STATUS, Buffer.from([0x00])]
|
|
579
|
+
]);
|
|
580
|
+
} catch (_) {}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ─── Reading the verification code out of a push ─────────────────────────────
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The WhatsApp verification code carried by a silent push, or null.
|
|
588
|
+
*
|
|
589
|
+
* The payload is JSON; WhatsApp files the code under "regcode". Anything else
|
|
590
|
+
* on the stream — a message notification, a call, a payload that is not JSON at
|
|
591
|
+
* all — reads as null and is simply not the push being waited for.
|
|
592
|
+
*
|
|
593
|
+
* @param {{fields: Map<number, Buffer[]>}} packet
|
|
594
|
+
* @returns {string|null}
|
|
595
|
+
*/
|
|
596
|
+
function extractRegCode(packet) {
|
|
597
|
+
const payload = first(packet.fields, F_NOTIFICATION_PAYLOAD);
|
|
598
|
+
if (!payload) return null;
|
|
599
|
+
try {
|
|
600
|
+
const json = JSON.parse(payload.toString('utf8'));
|
|
601
|
+
const code = json && json.regcode;
|
|
602
|
+
return code ? String(code) : null;
|
|
603
|
+
} catch (_) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
module.exports = {
|
|
609
|
+
ApnsCourierConnection,
|
|
610
|
+
extractRegCode,
|
|
611
|
+
fetchBag,
|
|
612
|
+
bagUrls,
|
|
613
|
+
parseBag,
|
|
614
|
+
encodeFrame,
|
|
615
|
+
decodeFields,
|
|
616
|
+
first,
|
|
617
|
+
topicHash,
|
|
618
|
+
createNonce,
|
|
619
|
+
certificateDer,
|
|
620
|
+
TAG,
|
|
621
|
+
DEFAULT_BAG,
|
|
622
|
+
F_NOTIFICATION_TOPIC,
|
|
623
|
+
F_NOTIFICATION_PAYLOAD,
|
|
624
|
+
F_NOTIFICATION_ID
|
|
625
|
+
};
|