whalibmob 5.32.2 → 5.33.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.
@@ -0,0 +1,675 @@
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
+ * Plain HTTP is the canonical URL and the one that actually answers: the bag is
219
+ * signed rather than encrypted, and the host does not serve a certificate for
220
+ * its own name — it presents one for images.apple.com, so HTTPS fails the
221
+ * hostname check every time. It is tried second only in case that ever changes.
222
+ *
223
+ * With a proxy configured the order inverts and HTTP is dropped entirely: the
224
+ * SOCKS agent is an https.Agent, so a plain-HTTP attempt would go out direct,
225
+ * around the proxy the user set precisely so that nothing does. Losing the bag
226
+ * costs nothing — the default pool below is the same answer it would have
227
+ * given.
228
+ *
229
+ * @returns {string[]}
230
+ */
231
+ function bagUrls() {
232
+ if (socksProxyUrl()) return ['https://' + BAG_HOST + BAG_PATH];
233
+ return ['http://' + BAG_HOST + BAG_PATH, 'https://' + BAG_HOST + BAG_PATH];
234
+ }
235
+
236
+ async function fetchBag() {
237
+ for (const url of bagUrls()) {
238
+ try {
239
+ const res = await httpGet(url);
240
+ if (res.status < 200 || res.status >= 300) throw new Error('HTTP ' + res.status);
241
+ const bag = parseBag(res.body);
242
+ _whaDbg('[DBG] APNs bag: ' + bag.hostCount + ' hosts at ' + bag.hostname);
243
+ return bag;
244
+ } catch (err) {
245
+ _whaDbg('[DBG] APNs bag fetch failed (' + url + '): ' + (err && err.message));
246
+ }
247
+ }
248
+ _whaDbg('[DBG] APNs bag unavailable — using the default courier pool');
249
+ return Object.assign({}, DEFAULT_BAG);
250
+ }
251
+
252
+ // ─── Connection credentials ───────────────────────────────────────────────────
253
+
254
+ /** SHA-1 of a topic name — how the wire names an iOS bundle id. */
255
+ function topicHash(topic) {
256
+ return crypto.createHash('sha1').update(String(topic), 'utf8').digest();
257
+ }
258
+
259
+ // 17 bytes: a zero, the current time in milliseconds, and eight random ones.
260
+ // Apple checks the timestamp is recent, which is what stops a captured
261
+ // signature from being replayed.
262
+ function createNonce() {
263
+ const nonce = Buffer.alloc(17);
264
+ nonce.writeBigUInt64BE(BigInt(Date.now()), 1);
265
+ crypto.randomBytes(8).copy(nonce, 9);
266
+ return nonce;
267
+ }
268
+
269
+ // The signature is prefixed with a two-byte version tag. Apple rejects the bare
270
+ // signature, so the tag is part of the field rather than framing around it.
271
+ const NONCE_SIGNATURE_TAG = Buffer.from([0x01, 0x01]);
272
+
273
+ function signNonce(privateKeyDer, nonce) {
274
+ const key = crypto.createPrivateKey({ key: privateKeyDer, format: 'der', type: 'pkcs8' });
275
+ return Buffer.concat([NONCE_SIGNATURE_TAG, crypto.sign('sha1', nonce, key)]);
276
+ }
277
+
278
+ // Apple hands the activation certificate back in whatever encoding it pleases
279
+ // (the activation record carries PEM); the courier wants DER. Round-tripping it
280
+ // through X509Certificate normalises both cases and rejects a corrupt one here,
281
+ // where the error still says what it is.
282
+ function certificateDer(certificate) {
283
+ return new crypto.X509Certificate(certificate).raw;
284
+ }
285
+
286
+ // A certificate field can repeat, and Node hands a repeated one back as an
287
+ // array, so every read goes through this rather than assuming a string.
288
+ function issuerValues(issuer, field) {
289
+ if (!issuer || issuer[field] == null) return [];
290
+ return [].concat(issuer[field]).map(String);
291
+ }
292
+
293
+ /**
294
+ * Whether the courier's certificate was issued by Apple.
295
+ *
296
+ * A middlebox that terminates TLS has to present its own certificate, signed
297
+ * by its own CA, because it cannot hold Apple's key. So the issuer's
298
+ * organisation is the one field that tells the real courier from something
299
+ * standing in front of it.
300
+ *
301
+ * @param {object|null} issuer the issuer from getPeerCertificate()
302
+ * @returns {boolean}
303
+ */
304
+ function isApplePeer(issuer) {
305
+ return issuerValues(issuer, 'O').includes('Apple Inc.');
306
+ }
307
+
308
+ /** A readable name for an issuer, for a log line or an error message. */
309
+ function describeIssuer(issuer) {
310
+ const name = issuerValues(issuer, 'CN')[0] || issuerValues(issuer, 'O')[0];
311
+ return name ? '"' + name + '"' : 'an unnamed issuer';
312
+ }
313
+
314
+ // ─── The connection ───────────────────────────────────────────────────────────
315
+
316
+ class ApnsCourierConnection {
317
+ /**
318
+ * @param {object} session { privateKeyDer, publicKeyDer, deviceCertificate, deviceToken? }
319
+ * all Buffers; deviceToken is the one from a previous
320
+ * connection, re-presented so Apple issues the same
321
+ * one again
322
+ * @param {object} [opts] { topics, onNotification, onLost }
323
+ */
324
+ constructor(session, opts) {
325
+ opts = opts || {};
326
+ this.session = session;
327
+ this.topics = Array.isArray(opts.topics) ? opts.topics.slice() : [];
328
+ this.onNotification = typeof opts.onNotification === 'function' ? opts.onNotification : null;
329
+ this.onLost = typeof opts.onLost === 'function' ? opts.onLost : null;
330
+
331
+ this.socket = null;
332
+ // Who signed the certificate the courier presented, set by the TLS
333
+ // handshake. Null until a socket exists, so a failure that happens before
334
+ // one does carries no note about interception.
335
+ this.peerIssuer = null;
336
+ this.deviceToken = session.deviceToken || null;
337
+ // Set by the handshake when Apple hands back a device token other than the
338
+ // one presented. False until then, including before any connection.
339
+ this.tokenReissued = false;
340
+ this.closed = false;
341
+ this.buffer = Buffer.alloc(0);
342
+ this.pending = [];
343
+ this.keepAliveTimer = null;
344
+ }
345
+
346
+ // ── lifecycle ──
347
+
348
+ async connect() {
349
+ const bag = await fetchBag();
350
+ // Host 0 does not exist; the pool is 1..hostCount.
351
+ const index = 1 + Math.floor(Math.random() * Math.max(1, bag.hostCount - 1));
352
+ const host = index + '-' + bag.hostname;
353
+
354
+ _whaDbg('[DBG] APNs courier dialling ' + host);
355
+ this.socket = await this._dial(host);
356
+ this.socket.on('data', chunk => this._onData(chunk));
357
+ this.socket.on('error', err => this._fail(err));
358
+ this.socket.on('close', () => this._fail(new Error('courier connection closed')));
359
+
360
+ try {
361
+ await this._handshake();
362
+ if (this.topics.length) this._sendFilter();
363
+ this._startKeepAlive();
364
+ } catch (err) {
365
+ // A handshake that did not finish leaves a socket nobody will ever read
366
+ // from. Callers do close on failure, but the connection should not depend
367
+ // on them remembering to.
368
+ this.close();
369
+ // A failure on a connection whose certificate Apple did not sign is the
370
+ // signature of TLS being terminated in the middle: the courier never saw
371
+ // the frame, something else did and hung up. Worth naming, because the
372
+ // error on its own reads like Apple refused the credentials.
373
+ if (this.peerIssuer && !isApplePeer(this.peerIssuer)) {
374
+ err.message += ' (the courier presented a certificate from ' +
375
+ describeIssuer(this.peerIssuer) + ', not Apple — TLS is being' +
376
+ ' intercepted on this network)';
377
+ }
378
+ throw err;
379
+ }
380
+ return this;
381
+ }
382
+
383
+ async _dial(host) {
384
+ const proxyUrl = socksProxyUrl();
385
+ const base = proxyUrl
386
+ ? await socksConnect(proxyUrl, host, COURIER_PORT, CONNECT_TIMEOUT_MS)
387
+ : null;
388
+
389
+ return new Promise((resolve, reject) => {
390
+ const options = {
391
+ host,
392
+ port: COURIER_PORT,
393
+ servername: host,
394
+ // Offered as every client offers it. Apple accepts it without echoing
395
+ // it back — see the issuer check below for what that means.
396
+ ALPNProtocols: [COURIER_ALPN],
397
+ // The courier answers with a certificate from Apple Server
398
+ // Authentication CA, which chains to an Apple root that is not in
399
+ // Node's bundled trust store, so chain validation cannot succeed here
400
+ // and is not what protects this connection: the device proves itself
401
+ // with a signature over a fresh nonce, and every push that matters is
402
+ // a WhatsApp payload the registration server signs for separately.
403
+ // Verification stays on for every other socket in the library — this
404
+ // is the one endpoint that cannot use it.
405
+ rejectUnauthorized: false
406
+ };
407
+ if (base) options.socket = base;
408
+
409
+ const socket = tls.connect(options, () => {
410
+ socket.setTimeout(0);
411
+ socket.setNoDelay(true);
412
+ // Who signed the certificate on the other end. This, not ALPN, is what
413
+ // tells a genuine courier from a middlebox: Apple's courier accepts the
414
+ // apns-security-v3 offer without echoing it back, so a missing echo is
415
+ // what the real thing looks like too. A first version refused any
416
+ // connection that did not echo it, and so refused Apple itself.
417
+ //
418
+ // The issuer is recorded, not enforced. The handshake decides; this
419
+ // only explains a failure afterwards.
420
+ const certificate = socket.getPeerCertificate() || {};
421
+ this.peerIssuer = certificate.issuer || null;
422
+ _whaDbg('[DBG] APNs courier certificate issued by ' + describeIssuer(this.peerIssuer));
423
+ resolve(socket);
424
+ });
425
+ socket.setTimeout(CONNECT_TIMEOUT_MS, () => socket.destroy(new Error('courier connect timed out')));
426
+ socket.once('error', reject);
427
+ });
428
+ }
429
+
430
+ async _handshake() {
431
+ const nonce = createNonce();
432
+ const fields = [
433
+ [F_CONNECT_STATE, CONNECT_STATE],
434
+ [F_CONNECT_FLAGS, CONNECT_FLAGS],
435
+ [F_CONNECT_CERT, certificateDer(this.session.deviceCertificate)],
436
+ [F_CONNECT_NONCE, nonce],
437
+ [F_CONNECT_SIGNATURE, signNonce(this.session.privateKeyDer, nonce)]
438
+ ];
439
+ // Presenting the previous device token asks Apple for the same one back.
440
+ // It has to be the same one: the per-topic push token already handed to
441
+ // WhatsApp is derived from it, and a new device token would leave that
442
+ // push_token addressing a connection nobody is listening on.
443
+ if (this.deviceToken) fields.unshift([F_CONNECT_TOKEN, this.deviceToken]);
444
+
445
+ const ready = await this._exchange(TAG.CONNECT, fields,
446
+ packet => packet.tag === TAG.READY);
447
+
448
+ const status = first(ready.fields, F_READY_STATUS);
449
+ if (!status || status.length === 0 || status[0] !== 0) {
450
+ throw new Error('courier refused CONNECT: status=' +
451
+ (status && status.length ? status[0] : 'none'));
452
+ }
453
+
454
+ const token = first(ready.fields, F_READY_TOKEN);
455
+ if (!token) throw new Error('courier READY carried no device token');
456
+
457
+ // A reissued device token invalidates every per-topic token derived from
458
+ // the old one, including the push_token WhatsApp may already hold. Say so,
459
+ // so the caller can drop what it cached rather than keep sending an address
460
+ // nothing answers on.
461
+ this.tokenReissued = Boolean(this.deviceToken && !this.deviceToken.equals(token));
462
+ if (this.tokenReissued) {
463
+ _whaDbg('[DBG] APNs device token was reissued — per-topic tokens have to be refetched');
464
+ }
465
+ this.deviceToken = Buffer.from(token);
466
+ _whaDbg('[DBG] APNs courier connected');
467
+
468
+ // Says the connection wants stored pushes delivered now. Without it a push
469
+ // that arrived while the device was offline stays parked at Apple.
470
+ this._send(TAG.STATE, [
471
+ [0x01, Buffer.from([0x01])],
472
+ [0x02, Buffer.from([0x7f, 0xff, 0xff, 0xff])]
473
+ ]);
474
+ }
475
+
476
+ close() {
477
+ if (this.closed) return;
478
+ this.closed = true;
479
+ if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
480
+ this.keepAliveTimer = null;
481
+ const socket = this.socket;
482
+ this.socket = null;
483
+ if (socket) { try { socket.destroy(); } catch (_) {} }
484
+ this._rejectPending(new Error('courier closed'));
485
+ }
486
+
487
+ // ── requests ──
488
+
489
+ /**
490
+ * The push token for one topic, hex-encoded — the value a WhatsApp
491
+ * registration carries as push_token.
492
+ *
493
+ * @param {string} topic an iOS bundle id, e.g. net.whatsapp.WhatsApp
494
+ * @returns {Promise<string>}
495
+ */
496
+ async requestToken(topic) {
497
+ const hash = topicHash(topic);
498
+ const packet = await this._exchange(TAG.GET_TOKEN, [
499
+ [F_GET_TOKEN_TOKEN, this.deviceToken],
500
+ [F_GET_TOKEN_TOPIC, hash],
501
+ [F_GET_TOKEN_PAD, Buffer.from([0x00, 0x00])]
502
+ ], p => p.tag === TAG.TOKEN_RESPONSE &&
503
+ (!first(p.fields, F_TOKEN_RESPONSE_TOPIC) ||
504
+ first(p.fields, F_TOKEN_RESPONSE_TOPIC).equals(hash)));
505
+
506
+ const token = first(packet.fields, F_TOKEN_RESPONSE_TOKEN);
507
+ if (!token) throw new Error('courier TOKEN_RESPONSE carried no token');
508
+ return token.toString('hex');
509
+ }
510
+
511
+ // FILTER is the subscription: one field 0x02 per topic, all in one frame.
512
+ _sendFilter() {
513
+ const fields = [[0x01, this.deviceToken]];
514
+ for (const topic of this.topics) fields.push([0x02, topicHash(topic)]);
515
+ this._send(TAG.FILTER, fields);
516
+ _whaDbg('[DBG] APNs subscribed to ' + this.topics.join(', '));
517
+ }
518
+
519
+ _startKeepAlive() {
520
+ this.keepAliveTimer = setInterval(() => {
521
+ if (this.closed) return;
522
+ try { this._send(TAG.KEEP_ALIVE, []); } catch (_) {}
523
+ }, KEEP_ALIVE_MS);
524
+ if (this.keepAliveTimer.unref) this.keepAliveTimer.unref();
525
+ }
526
+
527
+ // ── wire ──
528
+
529
+ _send(tag, fields) {
530
+ if (!this.socket) throw new Error('courier is not connected');
531
+ this.socket.write(encodeFrame(tag, fields));
532
+ }
533
+
534
+ _exchange(tag, fields, match) {
535
+ return new Promise((resolve, reject) => {
536
+ const entry = {
537
+ match,
538
+ resolve,
539
+ reject,
540
+ timer: setTimeout(() => {
541
+ this._drop(entry);
542
+ reject(new Error('courier request timed out'));
543
+ }, REQUEST_TIMEOUT_MS)
544
+ };
545
+ if (entry.timer.unref) entry.timer.unref();
546
+ this.pending.push(entry);
547
+ try {
548
+ this._send(tag, fields);
549
+ } catch (err) {
550
+ this._drop(entry);
551
+ clearTimeout(entry.timer);
552
+ reject(err);
553
+ }
554
+ });
555
+ }
556
+
557
+ _drop(entry) {
558
+ const index = this.pending.indexOf(entry);
559
+ if (index >= 0) this.pending.splice(index, 1);
560
+ }
561
+
562
+ _rejectPending(err) {
563
+ const waiting = this.pending.splice(0, this.pending.length);
564
+ for (const entry of waiting) {
565
+ clearTimeout(entry.timer);
566
+ entry.reject(err);
567
+ }
568
+ }
569
+
570
+ _fail(err) {
571
+ if (this.closed) return;
572
+ this.closed = true;
573
+ if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
574
+ this.keepAliveTimer = null;
575
+ _whaDbg('[DBG] APNs courier lost: ' + (err && err.message));
576
+ const error = err instanceof Error ? err : new Error(String(err));
577
+ this._rejectPending(error);
578
+ // A listener waiting on a push has no other way to learn the line is gone,
579
+ // and waiting out a three-minute timeout on a dead socket helps nobody.
580
+ if (this.onLost) {
581
+ try { this.onLost(error); } catch (_) {}
582
+ }
583
+ }
584
+
585
+ _onData(chunk) {
586
+ this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
587
+ for (;;) {
588
+ if (this.buffer.length < 5) return;
589
+ const length = this.buffer.readUInt32BE(1);
590
+ if (this.buffer.length < 5 + length) return;
591
+ const tag = this.buffer[0];
592
+ const payload = this.buffer.subarray(5, 5 + length);
593
+ this.buffer = this.buffer.subarray(5 + length);
594
+ this._dispatch({ tag, fields: decodeFields(payload) });
595
+ }
596
+ }
597
+
598
+ _dispatch(packet) {
599
+ if (packet.tag === TAG.NOTIFICATION) {
600
+ this._acknowledge(packet);
601
+ if (this.onNotification) {
602
+ try { this.onNotification(packet); } catch (_) {}
603
+ }
604
+ }
605
+ for (const entry of this.pending) {
606
+ let hit = false;
607
+ try { hit = entry.match(packet); } catch (_) { hit = false; }
608
+ if (hit) {
609
+ this._drop(entry);
610
+ clearTimeout(entry.timer);
611
+ entry.resolve(packet);
612
+ return;
613
+ }
614
+ }
615
+ }
616
+
617
+ // An unacknowledged push is redelivered on every reconnect, so this is not
618
+ // politeness — it is what stops the same notification arriving forever.
619
+ _acknowledge(packet) {
620
+ const id = first(packet.fields, F_NOTIFICATION_ID);
621
+ if (!id) return;
622
+ try {
623
+ this._send(TAG.ACK, [
624
+ [F_ACK_TOKEN, this.deviceToken],
625
+ [F_ACK_ID, id],
626
+ [F_ACK_STATUS, Buffer.from([0x00])]
627
+ ]);
628
+ } catch (_) {}
629
+ }
630
+ }
631
+
632
+ // ─── Reading the verification code out of a push ─────────────────────────────
633
+
634
+ /**
635
+ * The WhatsApp verification code carried by a silent push, or null.
636
+ *
637
+ * The payload is JSON; WhatsApp files the code under "regcode". Anything else
638
+ * on the stream — a message notification, a call, a payload that is not JSON at
639
+ * all — reads as null and is simply not the push being waited for.
640
+ *
641
+ * @param {{fields: Map<number, Buffer[]>}} packet
642
+ * @returns {string|null}
643
+ */
644
+ function extractRegCode(packet) {
645
+ const payload = first(packet.fields, F_NOTIFICATION_PAYLOAD);
646
+ if (!payload) return null;
647
+ try {
648
+ const json = JSON.parse(payload.toString('utf8'));
649
+ const code = json && json.regcode;
650
+ return code ? String(code) : null;
651
+ } catch (_) {
652
+ return null;
653
+ }
654
+ }
655
+
656
+ module.exports = {
657
+ ApnsCourierConnection,
658
+ extractRegCode,
659
+ fetchBag,
660
+ bagUrls,
661
+ parseBag,
662
+ encodeFrame,
663
+ decodeFields,
664
+ first,
665
+ topicHash,
666
+ createNonce,
667
+ certificateDer,
668
+ isApplePeer,
669
+ describeIssuer,
670
+ TAG,
671
+ DEFAULT_BAG,
672
+ F_NOTIFICATION_TOPIC,
673
+ F_NOTIFICATION_PAYLOAD,
674
+ F_NOTIFICATION_ID
675
+ };