whalibmob 5.26.0 → 5.28.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/index.d.ts CHANGED
@@ -123,6 +123,14 @@ export interface WhalibmobClientOptions {
123
123
  autoFixNumber?: boolean;
124
124
  /** Send read receipts for incoming messages. Default `true`. */
125
125
  autoRead?: boolean;
126
+ /**
127
+ * Refuse every incoming call. Default `true`.
128
+ *
129
+ * Turning it off does not answer calls — nothing here can — it leaves the
130
+ * refusal to you, which is the only way to let some calls ring and reject the
131
+ * rest with `rejectCall()`.
132
+ */
133
+ autoRejectCalls?: boolean;
126
134
  /** Refresh the announced build from the platform's store before every handshake. Default `true`. */
127
135
  refreshVersion?: boolean;
128
136
  /** `true` enables debug logging; an object is handed to `pino` as-is. */
@@ -227,8 +235,24 @@ export interface PollSendResult extends SendResult {
227
235
  messageSecret: Buffer;
228
236
  }
229
237
 
238
+ /** What a message was a reply to, and who it mentioned. */
239
+ export interface DecodedContextInfo {
240
+ /** Id of the quoted message. */
241
+ stanzaId?: string;
242
+ /** Who wrote the quoted message. */
243
+ participant?: Jid;
244
+ remoteJid?: Jid;
245
+ mentionedJid?: Jid[];
246
+ /** The quoted message, decoded the same way as any other. */
247
+ quotedMessage?: DecodedMessage;
248
+ /** The quoted message's raw protobuf bytes, for re-encoding it verbatim. */
249
+ quotedMessageRaw?: Buffer;
250
+ }
251
+
230
252
  export interface DecodedBase {
231
253
  type: string;
254
+ /** Present when the message quoted another or carried mentions. */
255
+ contextInfo?: DecodedContextInfo;
232
256
  /**
233
257
  * The message arrived inside a view-once envelope. The content is decoded
234
258
  * normally — media included — this only records how it was sent.
@@ -632,7 +656,7 @@ export interface WhalibmobEvents {
632
656
  message: (msg: IncomingMessage) => void;
633
657
  receipt: (r: { type: string; id: string; from: Jid }) => void;
634
658
  presence: (p: { from: Jid; available: boolean }) => void;
635
- call: (c: { from: Jid }) => void;
659
+ call: (c: { from: Jid; id: string | null; status: 'offer' | 'ringing' | 'terminate' | 'unknown'; node: any }) => void;
636
660
  notification: (node: any) => void;
637
661
  decrypt_error: (e: { id: string; from: Jid; participant?: Jid; err: Error }) => void;
638
662
  session_refresh: (e: { node: any }) => void;
@@ -831,6 +855,12 @@ export declare class WhalibmobClient extends EventEmitter {
831
855
  */
832
856
  requestAppStateKeys(keyIds: string[]): Promise<string | null>;
833
857
 
858
+ /**
859
+ * Refuse one incoming call. Only reachable when the client was built with
860
+ * `autoRejectCalls: false`; the id and caller come off the `call` event.
861
+ */
862
+ rejectCall(callId: string, from: Jid): boolean;
863
+
834
864
  // ─── Account restriction ─────────────────────────────────────────────────
835
865
  fetchReachoutTimelock(): Promise<ReachoutTimelockState>;
836
866
  /** The last known state, recomputed; does not ask the server again. */
@@ -1314,6 +1344,38 @@ export declare const PushClient: LibModule;
1314
1344
  export declare const Fcm: LibModule;
1315
1345
  export declare const FcmMcs: LibModule;
1316
1346
 
1347
+ /**
1348
+ * X25519 through Node's own OpenSSL — a drop-in for `curve25519-js`, roughly
1349
+ * 19x faster on `sharedKey` and byte-identical to it.
1350
+ *
1351
+ * `sign` and `verify` are XEdDSA and stay on the JavaScript implementation:
1352
+ * Node's Ed25519 is a different scheme and would produce different bytes.
1353
+ */
1354
+ export declare const Curve: LibModule & {
1355
+ generateKeyPair(seed: Buffer | Uint8Array): { private: Buffer; public: Buffer };
1356
+ sharedKey(priv: Buffer | Uint8Array, pub: Buffer | Uint8Array): Buffer;
1357
+ sign(priv: Buffer | Uint8Array, message: Buffer | Uint8Array): Buffer;
1358
+ verify(pub: Buffer | Uint8Array, message: Buffer | Uint8Array, signature: Buffer | Uint8Array): boolean;
1359
+ /** Whether Node's X25519 is in use; `false` means the JavaScript fallback is. */
1360
+ NATIVE: boolean;
1361
+ };
1362
+
1363
+ /**
1364
+ * HKDF-SHA256 through Node's own OpenSSL — roughly 2.2x faster than the
1365
+ * JavaScript implementation and byte-identical to it. Returns a `Buffer`,
1366
+ * not the `ArrayBuffer` Node's own `hkdfSync` hands back.
1367
+ */
1368
+ export declare const Hkdf: LibModule & {
1369
+ hkdfSha256(
1370
+ ikm: Buffer | Uint8Array,
1371
+ salt: Buffer | Uint8Array | string,
1372
+ info: Buffer | Uint8Array | string,
1373
+ length: number
1374
+ ): Buffer;
1375
+ /** Whether Node's HKDF is in use; `false` means the JavaScript fallback is. */
1376
+ NATIVE: boolean;
1377
+ };
1378
+
1317
1379
  /** Linking to an account that already exists. */
1318
1380
  export declare const PairingCode: LibModule;
1319
1381
  export declare const CompanionPairing: LibModule;
package/index.js CHANGED
@@ -129,6 +129,8 @@ const WebVersion = require('./lib/WebVersion');
129
129
  const WebProto = require('./lib/webproto');
130
130
 
131
131
  const BinaryNode = require('./lib/BinaryNode');
132
+ const Curve = require('./lib/curve');
133
+ const Hkdf = require('./lib/hkdf');
132
134
  const Noise = require('./lib/noise');
133
135
  const WebSocketStream = require('./lib/WebSocketStream');
134
136
  const Socks = require('./lib/socks');
@@ -348,6 +350,11 @@ module.exports = {
348
350
  FcmMcs,
349
351
 
350
352
  // Linking to an account that already exists
353
+ // X25519, through Node's own OpenSSL. Drop-in for curve25519-js.
354
+ Curve,
355
+ // HKDF-SHA256, through Node's own OpenSSL.
356
+ Hkdf,
357
+
351
358
  PairingCode,
352
359
  CompanionPairing,
353
360
  QrPairing,
package/lib/Client.js CHANGED
@@ -496,6 +496,12 @@ class WhalibmobClient extends EventEmitter {
496
496
  // Blue-tick every message as it arrives. Set to false to make read receipts
497
497
  // explicit, sent only by a markRead() call.
498
498
  this.autoRead = opts.autoRead !== false;
499
+
500
+ // Every incoming call is refused unless this is turned off. Off does not
501
+ // mean calls are answered — nothing here can answer one — it means the
502
+ // refusal is left to the caller, which is the only way to let some calls
503
+ // ring and reject the rest. See rejectCall().
504
+ this._autoRejectCalls = opts.autoRejectCalls !== false;
499
505
  // Flipped by setOnline(). Delivery receipts sent while this is false carry
500
506
  // type="inactive", which the sender's app records but does not render.
501
507
  this._sendActiveReceipts = false;
@@ -4162,20 +4168,46 @@ class WhalibmobClient extends EventEmitter {
4162
4168
  }
4163
4169
  }
4164
4170
 
4165
- this.emit('call', { from: attrs.from || '', node });
4166
- // Reject call automatically
4167
- if (this._socket && this._connected) {
4168
- const offer = findChild(node, 'offer');
4169
- const callId = offer && offer.attrs && offer.attrs.call_id;
4170
- if (callId) {
4171
- this._socket.sendNode(new BinaryNode('call', {
4172
- to: attrs.from || '',
4173
- id: this._genMsgId()
4174
- }, [new BinaryNode('reject', { call_id: callId, call_creator: attrs.from || '' }, null)]));
4175
- }
4171
+ // What the stanza actually says, rather than the raw node alone: which call
4172
+ // it is, and what stage it is at. A caller deciding whether to reject needs
4173
+ // both, and digging them out of the node is not its job.
4174
+ const offer = findChild(node, 'offer');
4175
+ const relay = findChild(node, 'relaylatency');
4176
+ const term = findChild(node, 'terminate');
4177
+ const callId = (offer && offer.attrs && offer.attrs.call_id) ||
4178
+ (term && term.attrs && term.attrs.call_id) || null;
4179
+ const status = offer ? 'offer' : (term ? 'terminate' : (relay ? 'ringing' : 'unknown'));
4180
+
4181
+ this.emit('call', { from: attrs.from || '', id: callId, status, node });
4182
+
4183
+ // Reject every incoming call, unless the caller asked to decide for itself.
4184
+ // Turning this off does not answer calls — nothing here can — it only stops
4185
+ // the automatic refusal, so a caller can reject some and let the rest ring.
4186
+ if (this._autoRejectCalls && callId && status === 'offer') {
4187
+ this.rejectCall(callId, attrs.from || '');
4176
4188
  }
4177
4189
  }
4178
4190
 
4191
+ /**
4192
+ * Refuse one incoming call.
4193
+ *
4194
+ * Only needed when the client was built with `autoRejectCalls: false`; with
4195
+ * the default every call is refused before this could be reached. The call id
4196
+ * and the caller both come off the `call` event.
4197
+ *
4198
+ * @param {string} callId the `id` from the call event
4199
+ * @param {string} from the `from` from the call event
4200
+ * @returns {boolean} whether the refusal could be sent
4201
+ */
4202
+ rejectCall(callId, from) {
4203
+ if (!callId || !this._socket || !this._connected) return false;
4204
+ this._socket.sendNode(new BinaryNode('call', {
4205
+ to: from || '',
4206
+ id: this._genMsgId()
4207
+ }, [new BinaryNode('reject', { call_id: callId, call_creator: from || '' }, null)]));
4208
+ return true;
4209
+ }
4210
+
4179
4211
  // ─── Auth failure (server forces logout during active session) ──────────────
4180
4212
 
4181
4213
  // <failure> ends the connection, but not every reason means the session died.
@@ -5461,9 +5493,9 @@ class WhalibmobClient extends EventEmitter {
5461
5493
  // Web-mode history lands in its own files. A number can be registered over
5462
5494
  // SMS and separately linked as a companion, and only the companion ever
5463
5495
  // receives history — merging the two would be merging different accounts'
5464
- // views of the same number.
5465
- const phone = (this._store && this._store.phoneNumber) +
5466
- (this._mode === 'web' ? '.web' : '');
5496
+ // views of the same number. Same prefix the readers use.
5497
+ const phone = this._sessionFilePrefix();
5498
+ if (!phone) return;
5467
5499
  const sessionDir = this._sessionDir;
5468
5500
 
5469
5501
  _whaDbg('[DBG] HIST_SYNC start syncType=' + notification.syncType +
@@ -5680,15 +5712,25 @@ class WhalibmobClient extends EventEmitter {
5680
5712
  }
5681
5713
 
5682
5714
  /**
5683
- * getHistoryStore — load the persisted history store for this phone.
5684
- * Returns the parsed {phone}.history.json content or null if not yet synced.
5715
+ * The name every file of this session is built from.
5685
5716
  *
5686
- * @returns {object|null}
5717
+ * A number can be registered over SMS and separately linked as a companion,
5718
+ * and the two are different accounts' views of it, so the companion's files
5719
+ * carry a `.web` in the middle: 40712345678.web.history.json. Everything that
5720
+ * reads or writes one has to agree on that — a writer that adds the `.web`
5721
+ * and a reader that does not will not error, it will simply never find the
5722
+ * file it just wrote.
5687
5723
  */
5688
- getHistoryStore() {
5724
+ _sessionFilePrefix() {
5689
5725
  if (!this._store || !this._store.phoneNumber) return null;
5726
+ return this._store.phoneNumber + (this._mode === 'web' ? '.web' : '');
5727
+ }
5728
+
5729
+ _readSessionJson(suffix) {
5730
+ const prefix = this._sessionFilePrefix();
5731
+ if (!prefix) return null;
5690
5732
  const fs = require('fs');
5691
- const file = require('path').join(this._sessionDir, this._store.phoneNumber + '.history.json');
5733
+ const file = require('path').join(this._sessionDir, prefix + suffix);
5692
5734
  try {
5693
5735
  if (!fs.existsSync(file)) return null;
5694
5736
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -5696,19 +5738,25 @@ class WhalibmobClient extends EventEmitter {
5696
5738
  }
5697
5739
 
5698
5740
  /**
5699
- * getMessagesStore — load the persisted messages for this phone.
5700
- * Returns the parsed {phone}.messages.json content or null if not yet synced.
5741
+ * getHistoryStore — load the persisted history store for this session.
5742
+ * Returns the parsed {phone}.history.json content — {phone}.web.history.json
5743
+ * on a companion — or null if not yet synced.
5744
+ *
5745
+ * @returns {object|null}
5746
+ */
5747
+ getHistoryStore() {
5748
+ return this._readSessionJson('.history.json');
5749
+ }
5750
+
5751
+ /**
5752
+ * getMessagesStore — load the persisted messages for this session.
5753
+ * Returns the parsed {phone}.messages.json content — {phone}.web.messages.json
5754
+ * on a companion — or null if not yet synced.
5701
5755
  *
5702
5756
  * @returns {object|null}
5703
5757
  */
5704
5758
  getMessagesStore() {
5705
- if (!this._store || !this._store.phoneNumber) return null;
5706
- const fs = require('fs');
5707
- const file = require('path').join(this._sessionDir, this._store.phoneNumber + '.messages.json');
5708
- try {
5709
- if (!fs.existsSync(file)) return null;
5710
- return JSON.parse(fs.readFileSync(file, 'utf8'));
5711
- } catch (_) { return null; }
5759
+ return this._readSessionJson('.messages.json');
5712
5760
  }
5713
5761
 
5714
5762
  // ─── Public API ───────────────────────────────────────────────────────────
@@ -18,7 +18,7 @@
18
18
  // from that account.
19
19
 
20
20
  const crypto = require('crypto');
21
- const curveJs = require('curve25519-js');
21
+ const curveJs = require('./curve');
22
22
 
23
23
  const WA_ADV_ACCOUNT_SIG_PREFIX = Buffer.from([6, 0]);
24
24
  const WA_ADV_DEVICE_SIG_PREFIX = Buffer.from([6, 1]);
@@ -26,13 +26,13 @@
26
26
  */
27
27
 
28
28
  const crypto = require('crypto');
29
+ const { hkdfSha256 } = require('./hkdf');
29
30
  const https = require('https');
30
31
  const http = require('http');
31
32
  const zlib = require('zlib');
32
33
  const fs = require('fs');
33
34
  const path = require('path');
34
35
 
35
- const { hkdf } = require('@noble/hashes/hkdf');
36
36
  const { sha256 } = require('@noble/hashes/sha256');
37
37
 
38
38
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -442,7 +442,7 @@ function decodeHistorySync(buf) {
442
442
  function deriveHistoryKeys(mediaKeyBuf) {
443
443
  const ikm = Buffer.isBuffer(mediaKeyBuf) ? mediaKeyBuf : Buffer.from(mediaKeyBuf);
444
444
  const info = Buffer.from(HISTORY_HKDF_INFO, 'utf8');
445
- const expanded = Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0), info, EXPANDED_SIZE));
445
+ const expanded = hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
446
446
  return {
447
447
  iv: expanded.slice(0, IV_LEN),
448
448
  cipherKey: expanded.slice(IV_LEN, IV_LEN + KEY_LEN),
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { hkdfSha256 } = require('./hkdf');
4
5
  const https = require('https');
5
6
  const http = require('http');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
- const { hkdf } = require('@noble/hashes/hkdf');
9
9
  const { sha256 } = require('@noble/hashes/sha256');
10
10
  const { getDeviceConfig } = require('./DeviceConfig');
11
11
 
@@ -69,7 +69,7 @@ const MAC_LEN = 10;
69
69
  function deriveMediaKeyData(mediaKey, keyName) {
70
70
  const ikm = Buffer.isBuffer(mediaKey) ? mediaKey : Buffer.from(mediaKey);
71
71
  const info = Buffer.from(keyName, 'utf8');
72
- return Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0), info, EXPANDED_SIZE));
72
+ return hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
73
73
  }
74
74
 
75
75
  function encryptMedia(plaintext, mediaKey, keyName) {
@@ -25,7 +25,8 @@
25
25
  // in normally.
26
26
 
27
27
  const crypto = require('crypto');
28
- const curveJs = require('curve25519-js');
28
+ const { hkdfSha256 } = require('./hkdf');
29
+ const curveJs = require('./curve');
29
30
 
30
31
  // Crockford base32 minus the ambiguous characters — no I, O, U, and no 0.
31
32
  // Five random bytes become exactly eight characters.
@@ -85,15 +86,12 @@ function aesEncryptGCM(plaintext, key, iv, aad) {
85
86
  }
86
87
 
87
88
  function hkdf(input, length, info, salt) {
88
- const { hkdf: nobleHkdf } = require('@noble/hashes/hkdf');
89
- const { sha256 } = require('@noble/hashes/sha256');
90
- return Buffer.from(nobleHkdf(
91
- sha256,
89
+ return hkdfSha256(
92
90
  input,
93
91
  salt || Buffer.alloc(32),
94
92
  info ? Buffer.from(info, 'utf8') : Buffer.alloc(0),
95
93
  length
96
- ));
94
+ );
97
95
  }
98
96
 
99
97
  function stripKeyPrefix(pub) {
@@ -6,7 +6,7 @@ const tls = require('tls');
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
- const curveJs = require('curve25519-js');
9
+ const curveJs = require('./curve');
10
10
  const { v4: uuidv4 } = require('uuid');
11
11
  const { getDeviceConfig } = require('./DeviceConfig');
12
12
  const AndroidApk = require('./AndroidApk');
package/lib/Store.js CHANGED
@@ -5,7 +5,7 @@ const { warn: _whaWarn, dbg: _whaDbg } = require('./logger');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const crypto = require('crypto');
8
- const curveJs = require('curve25519-js');
8
+ const curveJs = require('./curve');
9
9
  const { v4: uuidv4 } = require('uuid');
10
10
  const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK,
11
11
  platformForOs, isBusinessPlatform } = require('./constants');
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const { hkdf } = require('@noble/hashes/hkdf');
4
+ const { hkdfSha256 } = require('../hkdf');
5
5
  const { sha256 } = require('@noble/hashes/sha256');
6
6
  const { makeGenerator, newState } = require('./LTHash');
7
7
  const S = require('./SyncdProto');
@@ -19,8 +19,8 @@ const KEY_EXPANSION_LEN = 160;
19
19
 
20
20
  function mutationKeys(keyData) {
21
21
  const ikm = Buffer.isBuffer(keyData) ? keyData : Buffer.from(keyData);
22
- const e = Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0),
23
- Buffer.from(KEY_EXPANSION_INFO, 'utf8'), KEY_EXPANSION_LEN));
22
+ const e = hkdfSha256(ikm, Buffer.alloc(0),
23
+ Buffer.from(KEY_EXPANSION_INFO, 'utf8'), KEY_EXPANSION_LEN);
24
24
  return {
25
25
  indexKey: e.subarray(0, 32),
26
26
  valueEncryptionKey: e.subarray(32, 64),
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
+ const { hkdfSha256 } = require('../hkdf');
2
3
 
3
- const { hkdf } = require('@noble/hashes/hkdf');
4
- const { sha256 } = require('@noble/hashes/sha256');
5
4
 
6
5
  // A summation hash that survives being edited out of order.
7
6
  //
@@ -30,9 +29,7 @@ class LTHash {
30
29
  // 128 bytes of key material for one value.
31
30
  _expand(value) {
32
31
  const ikm = Buffer.isBuffer(value) ? value : Buffer.from(value);
33
- return Buffer.from(
34
- hkdf(sha256, ikm, Buffer.alloc(0), Buffer.from(this.salt, 'utf8'), HASH_BYTES)
35
- );
32
+ return hkdfSha256(ikm, Buffer.alloc(0), Buffer.from(this.salt, 'utf8'), HASH_BYTES);
36
33
  }
37
34
 
38
35
  // Word-by-word, 16 bits at a time, little-endian, wrapping at 2^16.
package/lib/curve.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // X25519, through Node's own OpenSSL rather than a JavaScript field arithmetic
4
+ // implementation.
5
+ //
6
+ // Every session setup and every ratchet step does a Diffie-Hellman, and every
7
+ // registration derives 812 pre-keys, so this is the busiest arithmetic in the
8
+ // library. Measured on the same machine, over 3000 iterations each:
9
+ //
10
+ // sharedKey curve25519-js 0.765 ms/op Node 0.040 ms/op 19x
11
+ // generateKeyPair curve25519-js 0.779 ms/op Node 0.085 ms/op 9x
12
+ //
13
+ // The results are byte-identical, which is the only reason this swap is
14
+ // allowed to exist — verified over 200 random key pairs in test/curve.test.js
15
+ // rather than assumed from the fact that both claim to implement RFC 7748.
16
+ //
17
+ // Two things are deliberately NOT native:
18
+ //
19
+ // sign / verify are XEdDSA — signing with a Montgomery key, which is what
20
+ // Signal specifies and what WhatsApp's device identities are signed with.
21
+ // Node has Ed25519, which is a different scheme over a different encoding of
22
+ // the same curve; swapping one for the other would produce different bytes
23
+ // and every signature this library has ever written would stop verifying.
24
+ // Those two stay on curve25519-js, and that is why it is still a dependency.
25
+ //
26
+ // The private half of generateKeyPair is clamped here rather than left as the
27
+ // seed. curve25519-js clamps it before returning, callers store what they are
28
+ // given, and a session written with one and read with the other would not
29
+ // agree. Node clamps internally at use time and hands back the raw seed, so
30
+ // the clamp has to be done explicitly to keep the returned pair identical.
31
+ //
32
+ // The API is curve25519-js's, unchanged, so this is a drop-in for it.
33
+
34
+ const crypto = require('crypto');
35
+ const curveJs = require('curve25519-js');
36
+
37
+ // The DER wrappers Node wants around a bare 32-byte X25519 key. Both are fixed
38
+ // prefixes — the algorithm identifier and the length header — so a raw key
39
+ // becomes an importable one by concatenation.
40
+ const PRIVATE_KEY_DER_PREFIX = Buffer.from([
41
+ 48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 110, 4, 34, 4, 32
42
+ ]);
43
+ const PUBLIC_KEY_DER_PREFIX = Buffer.from([
44
+ 48, 42, 48, 5, 6, 3, 43, 101, 110, 3, 33, 0
45
+ ]);
46
+
47
+ // Whether this Node was built with X25519. Everything below falls back to the
48
+ // JavaScript implementation when it was not, so an unusual build keeps working
49
+ // rather than failing at the first handshake.
50
+ const NATIVE = (() => {
51
+ try {
52
+ if (typeof crypto.diffieHellman !== 'function') return false;
53
+ const seed = Buffer.alloc(32, 1);
54
+ _importPrivate(seed);
55
+ return true;
56
+ } catch (_) { return false; }
57
+ })();
58
+
59
+ function _importPrivate(raw32) {
60
+ return crypto.createPrivateKey({
61
+ key: Buffer.concat([PRIVATE_KEY_DER_PREFIX, raw32]),
62
+ format: 'der',
63
+ type: 'pkcs8'
64
+ });
65
+ }
66
+
67
+ function _importPublic(raw32) {
68
+ return crypto.createPublicKey({
69
+ key: Buffer.concat([PUBLIC_KEY_DER_PREFIX, raw32]),
70
+ format: 'der',
71
+ type: 'spki'
72
+ });
73
+ }
74
+
75
+ // The clamp RFC 7748 specifies for X25519 private scalars, applied to a copy so
76
+ // a caller's seed is never modified underneath it.
77
+ function clamp(seed) {
78
+ const k = Buffer.from(seed);
79
+ k[0] &= 248;
80
+ k[31] &= 127;
81
+ k[31] |= 64;
82
+ return k;
83
+ }
84
+
85
+ /**
86
+ * A key pair from 32 bytes of seed.
87
+ *
88
+ * Returns the same shape and the same bytes as curve25519-js: the private half
89
+ * clamped, the public half derived from it.
90
+ *
91
+ * @param {Buffer|Uint8Array} seed 32 bytes
92
+ * @returns {{private: Buffer, public: Buffer}}
93
+ */
94
+ function generateKeyPair(seed) {
95
+ if (!seed || seed.length !== 32) {
96
+ throw new Error('generateKeyPair: seed must be 32 bytes, got ' +
97
+ (seed ? seed.length : 'nothing'));
98
+ }
99
+ if (!NATIVE) {
100
+ const kp = curveJs.generateKeyPair(seed);
101
+ return { private: Buffer.from(kp.private), public: Buffer.from(kp.public) };
102
+ }
103
+ const priv = clamp(seed);
104
+ const pub = crypto.createPublicKey(_importPrivate(priv))
105
+ .export({ format: 'der', type: 'spki' })
106
+ .subarray(PUBLIC_KEY_DER_PREFIX.length);
107
+ return { private: priv, public: Buffer.from(pub) };
108
+ }
109
+
110
+ /**
111
+ * The X25519 shared secret between our private key and their public key.
112
+ *
113
+ * @param {Buffer|Uint8Array} priv 32 bytes
114
+ * @param {Buffer|Uint8Array} pub 32 bytes
115
+ * @returns {Buffer} 32 bytes
116
+ */
117
+ function sharedKey(priv, pub) {
118
+ if (!priv || priv.length !== 32) throw new Error('sharedKey: private key must be 32 bytes');
119
+ if (!pub || pub.length !== 32) throw new Error('sharedKey: public key must be 32 bytes');
120
+ if (!NATIVE) return Buffer.from(curveJs.sharedKey(priv, pub));
121
+ return crypto.diffieHellman({
122
+ privateKey: _importPrivate(Buffer.from(priv)),
123
+ publicKey: _importPublic(Buffer.from(pub))
124
+ });
125
+ }
126
+
127
+ /**
128
+ * XEdDSA signature. Not native — see the note at the top of this file.
129
+ *
130
+ * @returns {Buffer} 64 bytes
131
+ */
132
+ function sign(priv, message) {
133
+ return Buffer.from(curveJs.sign(priv, message));
134
+ }
135
+
136
+ /** XEdDSA verification. Not native — see the note at the top of this file. */
137
+ function verify(pub, message, signature) {
138
+ return curveJs.verify(pub, message, signature);
139
+ }
140
+
141
+ module.exports = {
142
+ generateKeyPair,
143
+ sharedKey,
144
+ sign,
145
+ verify,
146
+ /** Whether Node's X25519 is being used. `false` means the JS fallback is. */
147
+ NATIVE
148
+ };
package/lib/hkdf.js ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // HKDF-SHA256, through Node's own OpenSSL.
4
+ //
5
+ // Every media key, every app-state key, every Noise handshake and every sender
6
+ // key goes through this, so it runs on nearly every operation the library
7
+ // performs. Measured over 20000 iterations at the 112-byte length the media
8
+ // path uses:
9
+ //
10
+ // @noble/hashes 20.99 us/op Node 9.38 us/op 2.2x
11
+ //
12
+ // Byte-identical over 300 random combinations of key material, salt and info
13
+ // at random lengths — checked in test/hkdf.test.js rather than assumed from
14
+ // both implementing RFC 5869.
15
+ //
16
+ // Two details this wrapper exists to get right:
17
+ //
18
+ // Node's hkdfSync returns an ArrayBuffer, not a Buffer. Handing one to code
19
+ // that expects a Buffer gives an object with no `.equals`, no `.subarray`
20
+ // returning a Buffer, and a `.length` that is `undefined` — mistakes that
21
+ // surface far from here. A Buffer is what comes back from this.
22
+ //
23
+ // The argument order is @noble's, so this is a drop-in for it, with the hash
24
+ // argument dropped since it is always SHA-256 here.
25
+
26
+ const crypto = require('crypto');
27
+
28
+ // Whether this Node has hkdfSync. It arrived in Node 15; the fallback keeps an
29
+ // older one working rather than failing at the first key derivation.
30
+ const NATIVE = typeof crypto.hkdfSync === 'function';
31
+
32
+ let _nobleHkdf = null, _nobleSha = null;
33
+ function _noble() {
34
+ if (!_nobleHkdf) {
35
+ _nobleHkdf = require('@noble/hashes/hkdf').hkdf;
36
+ _nobleSha = require('@noble/hashes/sha256').sha256;
37
+ }
38
+ return { hkdf: _nobleHkdf, sha256: _nobleSha };
39
+ }
40
+
41
+ /**
42
+ * Expand key material into `length` bytes.
43
+ *
44
+ * @param {Buffer|Uint8Array} ikm the input key material
45
+ * @param {Buffer|Uint8Array|string} salt may be empty
46
+ * @param {Buffer|Uint8Array|string} info may be empty
47
+ * @param {number} length how many bytes to produce
48
+ * @returns {Buffer}
49
+ */
50
+ function hkdfSha256(ikm, salt, info, length) {
51
+ if (!NATIVE) {
52
+ const n = _noble();
53
+ return Buffer.from(n.hkdf(n.sha256, ikm, salt, info, length));
54
+ }
55
+ // Buffer.from(ArrayBuffer) is a view over the same memory rather than a copy,
56
+ // which is fine here because the ArrayBuffer is freshly allocated by
57
+ // hkdfSync and nothing else holds a reference to it.
58
+ return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, length));
59
+ }
60
+
61
+ module.exports = {
62
+ hkdfSha256,
63
+ /** Whether Node's HKDF is in use; `false` means the JavaScript fallback is. */
64
+ NATIVE
65
+ };
@@ -28,8 +28,7 @@
28
28
  // the additional data.
29
29
 
30
30
  const crypto = require('crypto');
31
- const { hkdf } = require('@noble/hashes/hkdf');
32
- const { sha256 } = require('@noble/hashes/sha256');
31
+ const { hkdfSha256 } = require('../hkdf');
33
32
 
34
33
  const RETRY_KEY_INFO = 'WhatsApp Media Retry Notification';
35
34
  const IV_SIZE = 12;
@@ -89,7 +88,7 @@ function readFields(buf) {
89
88
  function mediaRetryKey(mediaKey) {
90
89
  const ikm = Buffer.isBuffer(mediaKey) ? mediaKey : Buffer.from(mediaKey);
91
90
  return Buffer.from(
92
- hkdf(sha256, ikm, Buffer.alloc(0), Buffer.from(RETRY_KEY_INFO, 'utf8'), 32));
91
+ hkdfSha256(ikm, Buffer.alloc(0), Buffer.from(RETRY_KEY_INFO, 'utf8'), 32));
93
92
  }
94
93
 
95
94
  /**
@@ -117,7 +117,7 @@ function _encodeADVSignedIdentity(details, sigKey, acctSig, devSig) {
117
117
 
118
118
  let _curve25519js = null;
119
119
  function _getCurve() {
120
- if (!_curve25519js) _curve25519js = require('curve25519-js');
120
+ if (!_curve25519js) _curve25519js = require('../curve');
121
121
  return _curve25519js;
122
122
  }
123
123
 
@@ -21,7 +21,7 @@
21
21
  // not observable on send.
22
22
 
23
23
  const crypto = require('crypto');
24
- const { hkdf } = require('@noble/hashes/hkdf');
24
+ const { hkdfSha256 } = require('../hkdf');
25
25
  const { sha256 } = require('@noble/hashes/sha256');
26
26
 
27
27
  // Protobuf field numbers on Message that the token is computed over.
@@ -224,7 +224,7 @@ function generateMsgSecretKey(modificationType, origMsgId, origMsgSender, modifi
224
224
  Buffer.from(String(modificationSender), 'utf8'),
225
225
  Buffer.from(modificationType, 'utf8')
226
226
  ]);
227
- return Buffer.from(hkdf(sha256, origMsgSecret, Buffer.alloc(32), useCaseSecret, 32));
227
+ return hkdfSha256(origMsgSecret, Buffer.alloc(32), useCaseSecret, 32);
228
228
  }
229
229
 
230
230
  // ─── reading what we need out of the encoded message ─────────────────────────
package/lib/noise.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const net = require('net');
4
+ const { hkdfSha256 } = require('./hkdf');
4
5
  const crypto = require('crypto');
5
6
  const EventEmitter = require('events');
6
- const curveJs = require('curve25519-js');
7
+ const curveJs = require('./curve');
7
8
  const { sha256 } = require('@noble/hashes/sha256');
8
- const { hkdf } = require('@noble/hashes/hkdf');
9
9
  const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT,
10
10
  platformForOs } = require('./constants');
11
11
  const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
@@ -224,7 +224,7 @@ function aesgcmDecrypt(key, counter, ciphertext, aad) {
224
224
  }
225
225
 
226
226
  function deriveHkdf(salt, ikm, length) {
227
- return Buffer.from(hkdf(sha256, ikm, salt, new Uint8Array(0), length));
227
+ return hkdfSha256(ikm, salt, new Uint8Array(0), length);
228
228
  }
229
229
 
230
230
  class NoiseState {
@@ -612,6 +612,80 @@ function _unwrap(envelope, innerField) {
612
612
  } catch (_) { return null; }
613
613
  }
614
614
 
615
+ // ─── ContextInfo, on the way in ──────────────────────────────────────────────
616
+ //
617
+ // Every message that can be a reply carries one, always at field 17 of its own
618
+ // content field — and field 7 for a group invite, which numbers its own fields.
619
+ // It is what says the message quotes another, and what the quoted message was:
620
+ //
621
+ // ContextInfo { stanzaId=1, participant=2, quotedMessage=3, remoteJid=4,
622
+ // mentionedJid=15 }
623
+ //
624
+ // Without it a reply is indistinguishable from a message sent on its own, so
625
+ // anything that acts on what was quoted — re-sending a view-once, turning a
626
+ // photo into a sticker — has nothing to act on.
627
+ const CONTEXT_BEARING_FIELDS = [
628
+ [6, 17], // extendedTextMessage
629
+ [3, 17], // imageMessage
630
+ [4, 17], // contactMessage
631
+ [5, 17], // locationMessage
632
+ [7, 17], // documentMessage
633
+ [8, 17], // audioMessage
634
+ [9, 17], // videoMessage
635
+ [26, 17], // stickerMessage
636
+ [49, 17], // pollCreationMessage
637
+ [28, 7] // groupInviteMessage — its contextInfo is field 7
638
+ ];
639
+
640
+ function decodeContextInfo(buf, depth) {
641
+ if (!buf || !Buffer.isBuffer(buf)) return null;
642
+ try {
643
+ const c = _decodeFields(buf);
644
+ const out = {};
645
+
646
+ const stanzaId = _str(c[1]);
647
+ if (stanzaId) out.stanzaId = stanzaId;
648
+ const participant = _str(c[2]);
649
+ if (participant) out.participant = participant;
650
+ const remoteJid = _str(c[4]);
651
+ if (remoteJid) out.remoteJid = remoteJid;
652
+
653
+ // Mentions are a repeated string, so one mention decodes to a bare value
654
+ // rather than to a list of one.
655
+ if (c[15] != null) {
656
+ const raw = Array.isArray(c[15]) ? c[15] : [c[15]];
657
+ const jids = raw.map(_str).filter(Boolean);
658
+ if (jids.length) out.mentionedJid = jids;
659
+ }
660
+
661
+ // The quoted message is a Message of its own, decoded the same way as any
662
+ // other. The depth bound is shared with the envelope unwrapping so a chain
663
+ // of replies quoting replies cannot recurse without end.
664
+ if (Buffer.isBuffer(c[3]) && c[3].length && (depth || 0) < MAX_WRAPPER_DEPTH) {
665
+ out.quotedMessageRaw = c[3];
666
+ const quoted = decodeMessageContainer(c[3], (depth || 0) + 1);
667
+ if (quoted && quoted.type !== 'unknown') out.quotedMessage = quoted;
668
+ }
669
+
670
+ return Object.keys(out).length ? out : null;
671
+ } catch (_) { return null; }
672
+ }
673
+
674
+ // Whichever content field the message turned out to be, its contextInfo.
675
+ function _contextInfoOf(f, depth) {
676
+ for (const [contentField, ctxField] of CONTEXT_BEARING_FIELDS) {
677
+ const content = f[contentField];
678
+ if (!Buffer.isBuffer(content)) continue;
679
+ try {
680
+ const inner = _decodeFields(content)[ctxField];
681
+ if (!Buffer.isBuffer(inner) || !inner.length) continue;
682
+ const ctx = decodeContextInfo(inner, depth);
683
+ if (ctx) return ctx;
684
+ } catch (_) { /* try the next one */ }
685
+ }
686
+ return null;
687
+ }
688
+
615
689
  function decodeMessageContainer(buf, depth) {
616
690
  if (!buf || buf.length === 0) return { type: 'unknown' };
617
691
  try {
@@ -953,6 +1027,13 @@ function decodeMessageContainer(buf, depth) {
953
1027
  msgResult = Object.assign({}, msgResult, { encKey: messageSecret });
954
1028
  }
955
1029
  if (skdmInfo && !msgResult.skdm) msgResult = Object.assign({}, msgResult, { skdm: skdmInfo });
1030
+ // What this message was a reply to, if anything. Read at the level that
1031
+ // actually carried the content, so an envelope does not overwrite the
1032
+ // context the message inside it already found.
1033
+ if (!msgResult.contextInfo) {
1034
+ const ctx = _contextInfoOf(f, level);
1035
+ if (ctx) msgResult = Object.assign({}, msgResult, { contextInfo: ctx });
1036
+ }
956
1037
  return msgResult;
957
1038
  }
958
1039
 
@@ -1025,5 +1106,6 @@ module.exports = {
1025
1106
  PROTOCOL_MSG_EPHEMERAL,
1026
1107
  GROUP_INVITE_TYPE_DEFAULT,
1027
1108
  GROUP_INVITE_TYPE_PARENT,
1028
- decodeMessageContainer
1109
+ decodeMessageContainer,
1110
+ decodeContextInfo
1029
1111
  };
@@ -1,14 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const { dbg: _whaDbg } = require('../logger');
4
+ const { hkdfSha256 } = require('../hkdf');
4
5
 
5
6
  const crypto = require('crypto');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
- const { hkdf } = require('@noble/hashes/hkdf');
9
9
  const { sha256 } = require('@noble/hashes/sha256');
10
10
  const { hmac } = require('@noble/hashes/hmac');
11
- const curveJs = require('curve25519-js');
11
+ const curveJs = require('../curve');
12
12
 
13
13
  // ─── Helpers ─────────────────────────────────────────────────────────────────
14
14
 
@@ -63,7 +63,7 @@ function advanceChain(chainKey) {
63
63
  function deriveMessageKeys(messageKey) {
64
64
  const salt = Buffer.alloc(32);
65
65
  const info = Buffer.from('WhisperGroup');
66
- const derived = Buffer.from(hkdf(sha256, messageKey, salt, info, 48));
66
+ const derived = hkdfSha256(messageKey, salt, info, 48);
67
67
  return {
68
68
  iv: derived.slice(0, 16),
69
69
  cipherKey: derived.slice(16, 48)
@@ -3,7 +3,7 @@ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('
3
3
 
4
4
  'use strict';
5
5
 
6
- const curveJs = require('curve25519-js');
6
+ const curveJs = require('../../curve');
7
7
  const nodeCrypto = require('crypto');
8
8
  // from: https://github.com/digitalbazaar/x25519-key-agreement-key-2019/blob/master/lib/crypto.js
9
9
  const PUBLIC_KEY_DER_PREFIX = Buffer.from([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.26.0",
3
+ "version": "5.28.0",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",