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.
@@ -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
+ };