whalibmob 5.29.2 → 5.29.3

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/README.md CHANGED
@@ -2473,6 +2473,7 @@ Run it once, type the code into the phone, and it is linked. Run it again and it
2473
2473
  | `client.connectWeb(phone, opts?)` | Open the companion connection. Resolves as soon as the encrypted channel is up when unlinked, or after `<success>` when already linked. |
2474
2474
  | `client.requestPairingCode(phone?, customCode?)` | Ask for an 8-character code. Returns it immediately; the link completes later. Throws if the session is already linked. |
2475
2475
  | `client.disconnect()` | Close the connection. The link survives — reconnect with `connectWeb()`. |
2476
+ | `client.logout()` | Unlink this device from the account, the way the Linked Devices screen does, then disconnect. The link does **not** survive — the next `connectWeb()` pairs afresh. Nothing on disk is deleted; remove the session directory yourself if you want the keys gone. |
2476
2477
 
2477
2478
  `connectWeb(phone, opts)` options:
2478
2479
 
@@ -3778,13 +3779,36 @@ The whole message object is accepted as well as its `decoded` half, so
3778
3779
 
3779
3780
  **Verifying the file**
3780
3781
 
3781
- Pass `{ verify: true }` to check the download against the message's
3782
- `fileEncSha256` before decrypting it. The MAC already proves the plaintext was
3783
- not tampered with; this catches a truncated or substituted download earlier, and
3784
- names that failure separately from a decryption one.
3782
+ Three things are checked, and all three by default — the message says what the
3783
+ file should be, so there is no reason to take the CDN's word for it:
3784
+
3785
+ | check | what it covers |
3786
+ |---|---|
3787
+ | `fileEncSha256` | the blob as it arrived, before any work is spent decrypting it |
3788
+ | the MAC | the ciphertext, under a key derived from `mediaKey` — this is the one that authenticates |
3789
+ | `fileSha256` | the decrypted file |
3790
+
3791
+ ```js
3792
+ const bytes = await client.downloadMedia(d) // all three
3793
+ const raw = await client.downloadMedia(d, { verify: false }) // MAC only
3794
+ ```
3795
+
3796
+ `verify: false` skips the two digests for a caller that wants the bytes whatever
3797
+ they are. The MAC is checked either way and is never optional.
3798
+
3799
+ **Link preview thumbnails**
3800
+
3801
+ A text message that quoted a link carries the preview the sender's client built.
3802
+ The small inline image is already on the message as `jpegThumbnail` and needs no
3803
+ download. The full-size one is a media file of its own — its own `directPath`,
3804
+ its own `mediaKey`, encrypted under a key name of its own — so `downloadMedia`
3805
+ cannot reach it:
3785
3806
 
3786
3807
  ```js
3787
- const bytes = await client.downloadMedia(d, { verify: true })
3808
+ if (d.thumbnail) {
3809
+ const preview = await client.downloadThumbnail(d)
3810
+ // d.title, d.description and d.matchedText are the rest of the preview
3811
+ }
3788
3812
  ```
3789
3813
 
3790
3814
  **What happens underneath**
@@ -3991,10 +4015,36 @@ const { id, encKey } = await client.sendPoll(
3991
4015
  ['JavaScript', 'Python', 'Rust'],
3992
4016
  1 // voters may pick 1 option (0 = unlimited)
3993
4017
  )
3994
- // encKey (32-byte Buffer) is needed to decrypt incoming poll votes
4018
+ // encKey (32-byte Buffer) is what reads the votes — keep it
3995
4019
  // (also returned as `messageSecret`, which is the name the protocol uses)
3996
4020
  ```
3997
4021
 
4022
+ **Reading the votes**
4023
+
4024
+ A vote arrives as its own message and the ballot inside it is encrypted, so
4025
+ nothing about who chose what is visible in the stanza. `decryptPollVote()` opens
4026
+ it with the poll's `encKey`:
4027
+
4028
+ ```js
4029
+ const options = ['JavaScript', 'Python', 'Rust']
4030
+
4031
+ client.on('message', (msg) => {
4032
+ if (msg.decoded?.type !== 'pollVote') return
4033
+ if (msg.decoded.pollKey.id !== id) return // a vote on some other poll
4034
+
4035
+ const { selected, votedAt } = client.decryptPollVote(msg, { encKey, options })
4036
+ console.log(msg.participant, 'voted for', selected) // [ 'Rust' ]
4037
+ })
4038
+ ```
4039
+
4040
+ Pass `options` and the choices come back as text. Without them you get
4041
+ `selectedHashes` — the SHA-256 of each chosen option, which is how the ballot
4042
+ names them on the wire — and match them yourself.
4043
+
4044
+ The key is derived from the poll id, the poll's sender and the voter, so a vote
4045
+ on someone else's poll needs their `encKey`, which only arrives if they sent the
4046
+ poll to you: read it off `msg.decoded.encKey` on the poll message itself.
4047
+
3998
4048
  ### Quoted Reply
3999
4049
 
4000
4050
  Send a text message that quotes (replies to) a specific earlier message. The recipient sees the original message highlighted above your reply.
package/index.d.ts CHANGED
@@ -271,6 +271,11 @@ export interface DecodedBase {
271
271
  ephemeral?: boolean;
272
272
  /** The message is the new text of an edit. */
273
273
  edited?: boolean;
274
+ /**
275
+ * On a text message that quoted a link, the downloadable preview image.
276
+ * Fetch it with `downloadThumbnail()`.
277
+ */
278
+ thumbnail?: LinkThumbnail;
274
279
  /**
275
280
  * Present only on a message this account sent from one of its other devices.
276
281
  * The stanza names us as the sender, so the chat it belongs to is written
@@ -280,6 +285,34 @@ export interface DecodedBase {
280
285
  [key: string]: any;
281
286
  }
282
287
 
288
+ /** The downloadable preview image on a message that quoted a link. */
289
+ export interface LinkThumbnail {
290
+ directPath: string;
291
+ mediaKey: Buffer;
292
+ thumbnailSha256: Buffer | null;
293
+ thumbnailEncSha256: Buffer | null;
294
+ }
295
+
296
+ export interface PollVoteOptions {
297
+ /** The poll's 32-byte message secret — what `sendPoll` returned. */
298
+ encKey: Buffer;
299
+ /** Who cast the vote. Defaults to the message's `participant`, then `from`. */
300
+ voter?: Jid;
301
+ /** Who sent the poll. Defaults to what the vote's message key names. */
302
+ pollSender?: Jid;
303
+ /** The poll's options, so the choices come back as text rather than hashes. */
304
+ options?: string[];
305
+ }
306
+
307
+ export interface PollVoteResult {
308
+ /** The chosen options as text. Empty unless `options` was passed. */
309
+ selected: string[];
310
+ /** SHA-256 of each chosen option, always present. */
311
+ selectedHashes: Buffer[];
312
+ /** When the vote was cast, in ms, when the sender said. */
313
+ votedAt: number | null;
314
+ }
315
+
283
316
  /** What a `DeviceSentMessage` envelope said about the message inside it. */
284
317
  export interface DeviceSentMeta {
285
318
  /** The chat the message was originally addressed to. */
@@ -856,6 +889,15 @@ export declare class WhalibmobClient extends EventEmitter {
856
889
  /** Ask for an 8-character pairing code. Throws if the session is already linked. */
857
890
  requestPairingCode(phoneNumber?: string, customCode?: string): Promise<string>;
858
891
  disconnect(opts?: DisconnectOptions): void;
892
+ /**
893
+ * Unlink this device from the account, the way the Linked Devices screen on
894
+ * the phone does, then close the connection.
895
+ *
896
+ * Web only. Nothing on disk is deleted — the session it describes no longer
897
+ * exists, so the next `connectWeb()` pairs afresh. Resolves to whether the
898
+ * server accepted the removal; the connection drops either way.
899
+ */
900
+ logout(): Promise<boolean>;
859
901
 
860
902
  /** Ask the server whether this session still logs in, and under which number. */
861
903
  checkSessionAlive(): Promise<SessionAliveProbe>;
@@ -882,8 +924,30 @@ export declare class WhalibmobClient extends EventEmitter {
882
924
  createCallLink(type?: 'audio' | 'video', opts?: { startTime?: number }): Promise<CallLink>;
883
925
 
884
926
  // ─── Receiving ───────────────────────────────────────────────────────────
885
- /** Download and decrypt a media message. Accepts `msg` or `msg.decoded`. */
927
+ /**
928
+ * Download and decrypt a media message. Accepts `msg` or `msg.decoded`.
929
+ *
930
+ * Both digests the message carried are checked by default, on top of the MAC
931
+ * that always is. `verify: false` skips the digests only.
932
+ */
886
933
  downloadMedia(decoded: DecodedMessage | IncomingMessage, opts?: { verify?: boolean }): Promise<Buffer>;
934
+ /**
935
+ * Download the full-size preview image of a message that quoted a link.
936
+ *
937
+ * A link preview's thumbnail is a media file of its own, encrypted under its
938
+ * own key name, so `downloadMedia` cannot reach it. The small inline preview
939
+ * needs no download — it is already on the message as `jpegThumbnail`.
940
+ */
941
+ downloadThumbnail(decoded: DecodedMessage | IncomingMessage, opts?: { verify?: boolean }): Promise<Buffer>;
942
+ /**
943
+ * Read a vote on a poll.
944
+ *
945
+ * The ballot is encrypted under a key derived from the poll's message secret:
946
+ * the `encKey` `sendPoll` returned, or `decoded.encKey` on a poll somebody
947
+ * else sent. Pass the poll's `options` to get the choices back as text —
948
+ * without them only the SHA-256 of each choice is available.
949
+ */
950
+ decryptPollVote(vote: DecodedMessage | IncomingMessage, opts: PollVoteOptions): PollVoteResult;
887
951
  /** Ask the sender's phone to re-upload a file the CDN no longer has. */
888
952
  requestMediaRetry(info: { id: string; chatJid: Jid; fromMe: boolean; participant?: Jid }, mediaKey: Buffer): Promise<void>;
889
953
  decryptMediaRetry(notification: MediaRetryNotification, mediaKey: Buffer): MediaRetryResult;
@@ -980,6 +1044,15 @@ export declare class WhalibmobClient extends EventEmitter {
980
1044
 
981
1045
  /** Pull pins/archives/mutes/stars/contact names changed elsewhere. */
982
1046
  syncAppState(names?: string[] | null, opts?: { snapshot?: boolean }): Promise<AppStateSyncResult>;
1047
+ /**
1048
+ * Acknowledge a dirty bit so the server stops announcing it.
1049
+ *
1050
+ * Done for you on every `<ib><dirty>` the server sends; this is here for a
1051
+ * bit you want to clear yourself. Pass the `timestamp` the announcement
1052
+ * carried — without it nothing in particular is acknowledged and the server
1053
+ * re-announces on the next connection.
1054
+ */
1055
+ markNotDirty(type: string, timestamp?: number | string): boolean;
983
1056
  /** Whether an app-state key is available — `false` on an SMS session with no companion. */
984
1057
  canSyncAppState(): boolean;
985
1058
  /**
package/lib/Client.js CHANGED
@@ -1898,8 +1898,18 @@ class WhalibmobClient extends EventEmitter {
1898
1898
  const dirtyTypes = [];
1899
1899
  for (const child of children) {
1900
1900
  if (!child || child.description !== 'dirty') continue;
1901
- const type = child.attrs && child.attrs.type;
1902
- if (type) dirtyTypes.push(type);
1901
+ const attrs = child.attrs || {};
1902
+ if (!attrs.type) continue;
1903
+ // The announcement carries the moment the change happened, and clearing
1904
+ // the bit means naming that moment back. A <clean> without it does not
1905
+ // acknowledge anything in particular, so the server keeps announcing the
1906
+ // same change on every connection from now on — which is what it did:
1907
+ // the timestamp was on the wire and never read off it. whatsmeow's
1908
+ // MarkNotDirty takes it as an argument for exactly this reason.
1909
+ dirtyTypes.push({
1910
+ type: String(attrs.type),
1911
+ timestamp: attrs.timestamp ? String(attrs.timestamp) : null
1912
+ });
1903
1913
  }
1904
1914
 
1905
1915
  if (dirtyTypes.length > 0) {
@@ -1922,15 +1932,23 @@ class WhalibmobClient extends EventEmitter {
1922
1932
  /**
1923
1933
  * Acknowledge a dirty bit so the server stops announcing it.
1924
1934
  *
1925
- * `<ib><dirty type="..."/></ib>` is the server saying something changed while
1926
- * we were away. Whatever the client does about it, the bit has to be cleared
1927
- * or the same announcement arrives on every connection.
1935
+ * `<ib><dirty type="..." timestamp="..."/></ib>` is the server saying
1936
+ * something changed while we were away, and when. Whatever the client does
1937
+ * about it, the bit has to be cleared or the same announcement arrives on
1938
+ * every connection.
1939
+ *
1940
+ * The timestamp is half of that acknowledgement: it says *which* change is
1941
+ * being cleared. Every `<clean>` this sent went out without one, because the
1942
+ * attribute was never read off the `<dirty>` that carried it — so nothing was
1943
+ * acknowledged in particular and the server kept announcing. whatsmeow's
1944
+ * MarkNotDirty takes it as an argument for the same reason.
1928
1945
  *
1929
1946
  * @param {string} type the dirty type, e.g. 'groups'
1930
1947
  * @param {number|string} [fromTs] the timestamp the announcement carried
1948
+ * @returns {boolean} whether the acknowledgement went out
1931
1949
  */
1932
- _cleanDirtyBits(type, fromTs) {
1933
- if (!this._socket || !this._connected) return;
1950
+ markNotDirty(type, fromTs) {
1951
+ if (!this._socket || !this._connected) return false;
1934
1952
  const attrs = { type: String(type) };
1935
1953
  if (fromTs) attrs.timestamp = String(fromTs);
1936
1954
  _whaDbg('[DBG] CLEAN_DIRTY type=' + type + (fromTs ? ' t=' + fromTs : ''));
@@ -1940,6 +1958,7 @@ class WhalibmobClient extends EventEmitter {
1940
1958
  type: 'set',
1941
1959
  xmlns: 'urn:xmpp:whatsapp:dirty'
1942
1960
  }, [new BinaryNode('clean', attrs, null)]));
1961
+ return true;
1943
1962
  }
1944
1963
 
1945
1964
  /**
@@ -1948,7 +1967,7 @@ class WhalibmobClient extends EventEmitter {
1948
1967
  * Best effort on purpose: the bit has to be cleared either way, or the server
1949
1968
  * announces the same change on every connection from now on.
1950
1969
  */
1951
- _refreshGroupsAfterDirty(type) {
1970
+ _refreshGroupsAfterDirty(type, fromTs) {
1952
1971
  Promise.resolve()
1953
1972
  .then(() => this.fetchAllGroups())
1954
1973
  .then(groups => {
@@ -1957,7 +1976,7 @@ class WhalibmobClient extends EventEmitter {
1957
1976
  .catch(err => {
1958
1977
  _whaDbg('[DBG] DIRTY groups — refresh failed: ' + (err && err.message));
1959
1978
  })
1960
- .then(() => this._cleanDirtyBits(type));
1979
+ .then(() => this.markNotDirty(type, fromTs));
1961
1980
  }
1962
1981
 
1963
1982
  _sendAppStateSyncForTypes(dirtyTypes) {
@@ -1974,8 +1993,10 @@ class WhalibmobClient extends EventEmitter {
1974
1993
  regular: ['regular']
1975
1994
  };
1976
1995
 
1996
+ // Each entry is { type, timestamp } — the timestamp is what the
1997
+ // announcement said, and it goes back on the <clean> that acknowledges it.
1977
1998
  const collections = new Set();
1978
- for (const type of dirtyTypes) {
1999
+ for (const { type, timestamp } of dirtyTypes) {
1979
2000
  const mapped = COLLECTION_MAP[type];
1980
2001
  if (!mapped) {
1981
2002
  if (type === 'groups') {
@@ -1983,14 +2004,14 @@ class WhalibmobClient extends EventEmitter {
1983
2004
  // away. Clearing it without looking leaves every cached group sitting
1984
2005
  // on the membership and settings it had before we disconnected, so
1985
2006
  // re-read them first and acknowledge after.
1986
- this._refreshGroupsAfterDirty(type);
2007
+ this._refreshGroupsAfterDirty(type, timestamp);
1987
2008
  continue;
1988
2009
  }
1989
2010
  // Not an app-state collection, but still a dirty bit the server expects
1990
2011
  // to be cleared. Ignoring it meant the server re-announced it on every
1991
2012
  // single connection, forever.
1992
2013
  _whaDbg('[DBG] DIRTY type=' + type + ' — not an app-state collection, clearing the bit');
1993
- this._cleanDirtyBits(type);
2014
+ this.markNotDirty(type, timestamp);
1994
2015
  continue;
1995
2016
  }
1996
2017
  for (const c of mapped) collections.add(c);
@@ -2003,8 +2024,8 @@ class WhalibmobClient extends EventEmitter {
2003
2024
  // session that registered its own number — would otherwise leave it set
2004
2025
  // forever and be told about the same change on every single connection.
2005
2026
  const clearAll = () => {
2006
- for (const type of dirtyTypes) {
2007
- if (COLLECTION_MAP[type]) this._cleanDirtyBits(type);
2027
+ for (const { type, timestamp } of dirtyTypes) {
2028
+ if (COLLECTION_MAP[type]) this.markNotDirty(type, timestamp);
2008
2029
  }
2009
2030
  };
2010
2031
 
@@ -3960,26 +3981,28 @@ class WhalibmobClient extends EventEmitter {
3960
3981
  // hash, and if it matches there is nothing to invalidate and nothing to
3961
3982
  // ask usync about — see DeviceManager.verifyDeviceDelta().
3962
3983
  if (fromUser && this._devMgr) {
3963
- const delta = this._parseDeviceDelta(node);
3964
- if (delta) {
3984
+ const steps = this._parseDeviceDelta(node);
3985
+ if (steps) {
3965
3986
  // The primary half is keyed the way the notification is addressed.
3966
- const priKey = isLid ? 'lid:' + fromUser : fromUser;
3967
- const priServer = isLid ? 'lid' : 's.whatsapp.net';
3968
- const priOk = this._devMgr.verifyDeviceDelta(
3969
- priKey, fromUser, delta.changes, delta.hash, priServer);
3970
-
3971
3987
  // The secondary half only exists when the notification arrived under
3972
3988
  // the number and named the LID alongside it.
3973
- if (!isLid && lidUser && delta.lidHash && delta.lidChanges) {
3974
- const lidOk = this._devMgr.verifyDeviceDelta(
3975
- 'lid:' + lidUser, lidUser, delta.lidChanges, delta.lidHash, 'lid');
3989
+ const { primaryOk, lidOk } = this._devMgr.applyDeviceDelta({
3990
+ priKey: isLid ? 'lid:' + fromUser : fromUser,
3991
+ priUser: fromUser,
3992
+ priServer: isLid ? 'lid' : 's.whatsapp.net',
3993
+ lidKey: (!isLid && lidUser) ? 'lid:' + lidUser : null,
3994
+ lidUser: (!isLid && lidUser) ? lidUser : null,
3995
+ steps
3996
+ });
3997
+
3998
+ if (!isLid && lidUser) {
3976
3999
  _whaDbg('[DBG] NOTIF_DEVICES lid delta ' +
3977
4000
  (lidOk ? 'verified' : 'unverified') + ' for lid:' + lidUser);
3978
4001
  }
3979
4002
 
3980
- if (priOk) {
3981
- _whaDbg('[DBG] NOTIF_DEVICES delta verified for ' + priKey +
3982
- ' — cache kept, no usync needed');
4003
+ if (primaryOk) {
4004
+ _whaDbg('[DBG] NOTIF_DEVICES delta verified over ' + steps.length +
4005
+ ' step(s) — cache kept, no usync needed');
3983
4006
  return;
3984
4007
  }
3985
4008
  }
@@ -4037,39 +4060,44 @@ class WhalibmobClient extends EventEmitter {
4037
4060
  }
4038
4061
 
4039
4062
  /**
4040
- * Read a `devices` notification as a change plus the hash it should produce.
4063
+ * Read a `devices` notification as an ordered list of steps.
4041
4064
  *
4042
4065
  * <notification type="devices" from="…">
4043
4066
  * <add device_hash="2:…"><device jid="40711111111:3@s.whatsapp.net"/></add>
4067
+ * <add device_hash="2:…"><device jid="40711111111:4@s.whatsapp.net"/></add>
4044
4068
  * </notification>
4045
4069
  *
4046
- * Children are `add`, `remove`, or `update`. Only the first two say something
4047
- * that can be applied; `update` does not name what it changed, so it is
4048
- * treated as unreadable here and the caller falls back to invalidating.
4070
+ * Each child is one change, and the `device_hash` on it is the hash of the
4071
+ * device list **after that change** — not of the notification as a whole. So
4072
+ * children in a multi-change notification carry different hashes, by design,
4073
+ * and each has to be checked against the list as it stands at that point.
4074
+ *
4075
+ * This used to demand that every child agree on one hash and give up when
4076
+ * they did not, which is exactly what a two-change notification looks like:
4077
+ * someone linking a laptop and a tablet in the same sitting produced two
4078
+ * children, two hashes, and the whole thing was thrown away — cache dropped,
4079
+ * full usync round-trip on the next send. Steps are now kept separately and
4080
+ * applied one at a time, the way whatsmeow's handleDeviceNotification does.
4049
4081
  *
4050
- * All children must agree on the hash — the server sends one per child, and a
4051
- * notification carrying two different ones is describing intermediate states
4052
- * this cannot reconstruct.
4082
+ * Children are `add`, `remove`, or `update`. `update` does not name what it
4083
+ * changed, so it survives as a step that tells the applier to drop the cache
4084
+ * and carry on with the rest. A tag this does not know is skipped, and a
4085
+ * child that cannot be read at all rejects the notification: guessing at a
4086
+ * change we could not parse is how a device list quietly goes wrong.
4053
4087
  *
4054
- * Each child may also carry `device_lid_hash`, and each `<device>` a `lid`
4055
- * beside its `jid`, describing the same change to the contact's LID device
4056
- * list. That half is optional: missing on any child, and only the phone-number
4057
- * half is returned, which is what the caller then verifies. It is never a
4058
- * reason to reject the notification outright — the two lists are hashed
4059
- * independently by the server and are checked independently here.
4088
+ * Each child may also carry `device_lid_hash`, and its `<device>` a `lid`
4089
+ * beside the `jid`, describing the same change to the contact's LID device
4090
+ * list. That half is per-step and optional — a step without it leaves the LID
4091
+ * list alone rather than invalidating the step, because the server hashes the
4092
+ * two lists independently and they are checked independently here.
4060
4093
  *
4061
- * @returns {{changes: Array<{tag, deviceId}>, hash: string,
4062
- * lidChanges: Array<{tag, deviceId}>|null, lidHash: string|null}|null}
4094
+ * @returns {Array<{tag: 'add'|'remove'|'update', deviceId: number|null,
4095
+ * hash: string|null, lidDeviceId: number|null,
4096
+ * lidHash: string|null}>|null}
4063
4097
  */
4064
4098
  _parseDeviceDelta(node) {
4065
4099
  if (!node || !Array.isArray(node.content)) return null;
4066
4100
 
4067
- const changes = [];
4068
- const lidChanges = [];
4069
- let hash = null;
4070
- let lidHash = null;
4071
- let lidOk = true; // every child so far carried a usable LID half
4072
-
4073
4101
  // "user:3@server" / "user@server" → 3 / 0, or null when unreadable.
4074
4102
  const deviceIdOf = (jid) => {
4075
4103
  if (!jid) return null;
@@ -4080,40 +4108,43 @@ class WhalibmobClient extends EventEmitter {
4080
4108
  return Number.isFinite(device) ? device : null;
4081
4109
  };
4082
4110
 
4111
+ const steps = [];
4112
+
4083
4113
  for (const child of node.content) {
4084
4114
  if (!child || !child.description) continue;
4085
4115
  const tag = child.description;
4086
- if (tag !== 'add' && tag !== 'remove') return null; // 'update' and friends
4087
4116
 
4088
- const childHash = child.attrs && child.attrs.device_hash;
4089
- if (!childHash) return null;
4090
- if (hash === null) hash = String(childHash);
4091
- else if (hash !== String(childHash)) return null; // disagreeing children
4117
+ if (tag === 'update') {
4118
+ steps.push({ tag, deviceId: null, hash: null, lidDeviceId: null, lidHash: null });
4119
+ continue;
4120
+ }
4121
+ if (tag !== 'add' && tag !== 'remove') continue; // not ours to interpret
4122
+
4123
+ const attrs = child.attrs || {};
4124
+ const hash = attrs.device_hash ? String(attrs.device_hash) : null;
4125
+ if (!hash) return null;
4092
4126
 
4093
4127
  const deviceNode = findChild(child, 'device');
4094
- const attrs = (deviceNode && deviceNode.attrs) || {};
4095
- const device = deviceIdOf(attrs.jid);
4096
- if (device === null) return null;
4097
- changes.push({ tag, deviceId: device });
4098
-
4099
- // LID half — optional, and giving up on it costs the number half nothing.
4100
- if (!lidOk) continue;
4101
- const childLidHash = child.attrs.device_lid_hash;
4102
- const lidDevice = deviceIdOf(attrs.lid);
4103
- if (!childLidHash || lidDevice === null) { lidOk = false; continue; }
4104
- if (lidHash === null) lidHash = String(childLidHash);
4105
- else if (lidHash !== String(childLidHash)) { lidOk = false; continue; }
4106
- lidChanges.push({ tag, deviceId: lidDevice });
4107
- }
4108
-
4109
- if (!changes.length || !hash) return null;
4110
- const haveLid = lidOk && lidHash && lidChanges.length === changes.length;
4111
- return {
4112
- changes,
4113
- hash,
4114
- lidChanges: haveLid ? lidChanges : null,
4115
- lidHash: haveLid ? lidHash : null
4116
- };
4128
+ const dattrs = (deviceNode && deviceNode.attrs) || {};
4129
+ const deviceId = deviceIdOf(dattrs.jid);
4130
+ if (deviceId === null) return null;
4131
+
4132
+ // Both halves of the LID pair or neither: a hash with no device to apply
4133
+ // it to, or a device with no hash to check it against, is not a step.
4134
+ const lidHash = attrs.device_lid_hash ? String(attrs.device_lid_hash) : null;
4135
+ const lidDeviceId = deviceIdOf(dattrs.lid);
4136
+ const haveLid = !!lidHash && lidDeviceId !== null;
4137
+
4138
+ steps.push({
4139
+ tag,
4140
+ deviceId,
4141
+ hash,
4142
+ lidDeviceId: haveLid ? lidDeviceId : null,
4143
+ lidHash: haveLid ? lidHash : null
4144
+ });
4145
+ }
4146
+
4147
+ return steps.length ? steps : null;
4117
4148
  }
4118
4149
 
4119
4150
  _processDeviceUpdate(devicesNode) {
@@ -6285,9 +6316,59 @@ class WhalibmobClient extends EventEmitter {
6285
6316
  const keyName = getMediaKeyName(KEY_NAME[d.type] || d.type);
6286
6317
  if (!keyName) throw new Error('downloadMedia: unsupported media type "' + d.type + '"');
6287
6318
 
6319
+ // Both digests go down by default. The message says what the file should
6320
+ // be; there is no reason to take the CDN's word for it. `verify: false`
6321
+ // turns them off for a caller that wants the bytes whatever they are — the
6322
+ // MAC is checked either way, and that is the one that authenticates.
6288
6323
  return downloadMedia(url, d.mediaKey, keyName, {
6289
6324
  web: this._mode === 'web',
6290
- fileEncSha256: opts.verify ? d.fileEncSha256 : undefined
6325
+ verify: opts.verify !== false,
6326
+ fileEncSha256: d.fileEncSha256,
6327
+ fileSha256: d.fileSha256
6328
+ });
6329
+ }
6330
+
6331
+ /**
6332
+ * Download the preview image of a message that quoted a link.
6333
+ *
6334
+ * A link preview's thumbnail is a media file in its own right — its own
6335
+ * directPath, its own mediaKey, its own digests — carried beside the text
6336
+ * rather than inside it, and encrypted under a key name of its own. So
6337
+ * `downloadMedia` cannot fetch it: that reads the message's own media, and a
6338
+ * text message has none. This is whatsmeow's DownloadThumbnail.
6339
+ *
6340
+ * The small inline preview is a different thing and needs no download: it is
6341
+ * already on the decoded message as `jpegThumbnail`.
6342
+ *
6343
+ * @param {object} decoded a decoded message, or the `message` event payload
6344
+ * @param {object} [opts] `verify: false` to skip the digest checks
6345
+ * @returns {Promise<Buffer>} the full-size preview image
6346
+ */
6347
+ async downloadThumbnail(decoded, opts) {
6348
+ opts = opts || {};
6349
+ const d = (decoded && decoded.decoded) ? decoded.decoded : decoded;
6350
+ if (!d) throw new Error('downloadThumbnail: a decoded message is required');
6351
+
6352
+ const thumb = d.thumbnail;
6353
+ if (!thumb || !thumb.mediaKey) {
6354
+ throw new Error('downloadThumbnail: this message carries no downloadable ' +
6355
+ 'thumbnail (a link preview without one, or not a link at all)');
6356
+ }
6357
+
6358
+ const { downloadMedia, getThumbnailKeyName, resolveMediaUrl } =
6359
+ require('./MediaService');
6360
+ const keyName = getThumbnailKeyName(d.type === 'text' ? 'text' : d.type);
6361
+ if (!keyName) {
6362
+ throw new Error('downloadThumbnail: no thumbnail key name for "' + d.type + '"');
6363
+ }
6364
+ const url = resolveMediaUrl(null, thumb.directPath);
6365
+ if (!url) throw new Error('downloadThumbnail: the thumbnail has no CDN location');
6366
+
6367
+ return downloadMedia(url, thumb.mediaKey, keyName, {
6368
+ web: this._mode === 'web',
6369
+ verify: opts.verify !== false,
6370
+ fileEncSha256: thumb.thumbnailEncSha256,
6371
+ fileSha256: thumb.thumbnailSha256
6291
6372
  });
6292
6373
  }
6293
6374
 
@@ -7290,6 +7371,169 @@ class WhalibmobClient extends EventEmitter {
7290
7371
  return this._sender.sendPoll(to, question, options, selectableCount, opts);
7291
7372
  }
7292
7373
 
7374
+ /**
7375
+ * Read a vote on a poll.
7376
+ *
7377
+ * A vote arrives as its own message and the ballot inside it is encrypted, so
7378
+ * nothing about who voted for what is visible in the stanza. The key is
7379
+ * derived from the poll's message secret — the `encKey` `sendPoll` returned,
7380
+ * or `decoded.encKey` on a poll somebody else sent us:
7381
+ *
7382
+ * key = HKDF-SHA256(secret, salt = ∅,
7383
+ * info = pollId ‖ pollSender ‖ voter ‖ "Poll Vote", 32)
7384
+ * AAD = pollId ‖ 0x00 ‖ voter
7385
+ * ballot = AES-256-GCM-open(key, encIv, encPayload, AAD)
7386
+ *
7387
+ * The ballot names the chosen options by SHA-256 of their text, not by index
7388
+ * or by name, so pass `options` — the poll's option strings, in any order —
7389
+ * and the choices come back as text. Without them you get the hashes and can
7390
+ * match them yourself.
7391
+ *
7392
+ * Both JIDs are taken without their device: a vote belongs to an account.
7393
+ *
7394
+ * This is whatsmeow's DecryptPollVote. Nothing here could read a vote before:
7395
+ * `sendPoll` handed back the key and left the rest to the caller.
7396
+ *
7397
+ * @param {object} vote the `message` event payload, or its `decoded`
7398
+ * @param {object} opts
7399
+ * @param {Buffer} opts.encKey the poll's message secret (aka messageSecret)
7400
+ * @param {string} [opts.voter] who voted; defaults to the message's sender
7401
+ * @param {string} [opts.pollSender] who sent the poll; defaults to the key's
7402
+ * remoteJid, or to the voter when the poll is
7403
+ * ours
7404
+ * @param {string[]} [opts.options] the poll's options, to name the choices
7405
+ * @returns {{selected: string[], selectedHashes: Buffer[], votedAt: number|null}}
7406
+ */
7407
+ decryptPollVote(vote, opts) {
7408
+ opts = opts || {};
7409
+ const d = (vote && vote.decoded) ? vote.decoded : vote;
7410
+ if (!d || d.type !== 'pollVote') {
7411
+ throw new Error('decryptPollVote: this is not a vote on a poll');
7412
+ }
7413
+ if (!d.encPayload || !d.encIv) {
7414
+ throw new Error('decryptPollVote: the vote carries no encrypted ballot');
7415
+ }
7416
+ const secret = opts.encKey || opts.messageSecret;
7417
+ if (!secret || secret.length !== 32) {
7418
+ throw new Error('decryptPollVote: the poll\'s 32-byte encKey is required — ' +
7419
+ 'it is what sendPoll returned, or decoded.encKey on a poll we received');
7420
+ }
7421
+
7422
+ const key = d.pollKey || {};
7423
+ const pollId = String(key.id || '');
7424
+ if (!pollId) throw new Error('decryptPollVote: the vote does not name a poll');
7425
+
7426
+ // Who voted, and who the poll belongs to. `fromMe` on the key means the
7427
+ // poll and the vote came from the same account, which is the one case the
7428
+ // key's remoteJid names the chat rather than the poll's author.
7429
+ const voter = toNonAdJid(String(
7430
+ opts.voter || (vote && vote.participant) || (vote && vote.from) || ''));
7431
+ if (!voter || voter.startsWith('@')) {
7432
+ throw new Error('decryptPollVote: cannot tell who cast this vote — pass opts.voter');
7433
+ }
7434
+ const pollSender = toNonAdJid(String(
7435
+ opts.pollSender || (key.fromMe ? voter : (key.remoteJid || voter))));
7436
+
7437
+ const info = Buffer.concat([
7438
+ Buffer.from(pollId, 'utf8'),
7439
+ Buffer.from(pollSender, 'utf8'),
7440
+ Buffer.from(voter, 'utf8'),
7441
+ Buffer.from('Poll Vote', 'utf8')
7442
+ ]);
7443
+ const { hkdfSha256 } = require('./hkdf');
7444
+ const voteKey = hkdfSha256(Buffer.from(secret), Buffer.alloc(0), info, 32);
7445
+
7446
+ const aad = Buffer.concat([
7447
+ Buffer.from(pollId, 'utf8'), Buffer.from([0]), Buffer.from(voter, 'utf8')
7448
+ ]);
7449
+
7450
+ // GCM's tag is the last 16 bytes of the payload, as it is everywhere else
7451
+ // in this protocol.
7452
+ const payload = Buffer.from(d.encPayload);
7453
+ const ciphertext = payload.subarray(0, payload.length - 16);
7454
+ const tag = payload.subarray(payload.length - 16);
7455
+
7456
+ let plaintext;
7457
+ try {
7458
+ const dec = crypto.createDecipheriv('aes-256-gcm', voteKey, Buffer.from(d.encIv));
7459
+ dec.setAAD(aad);
7460
+ dec.setAuthTag(tag);
7461
+ plaintext = Buffer.concat([dec.update(ciphertext), dec.final()]);
7462
+ } catch (_) {
7463
+ throw new Error('decryptPollVote: the ballot would not open — wrong encKey, ' +
7464
+ 'or the wrong voter/pollSender for this poll');
7465
+ }
7466
+
7467
+ // PollVoteMessage { selectedOptions = 1, repeated bytes } — each entry is
7468
+ // the SHA-256 of one option's text.
7469
+ const { decodeFields } = require('./proto/MessageProto');
7470
+ const parsed = decodeFields(plaintext);
7471
+ const raw = parsed[1] == null ? [] : (Array.isArray(parsed[1]) ? parsed[1] : [parsed[1]]);
7472
+ const selectedHashes = raw.filter(Buffer.isBuffer);
7473
+
7474
+ let selected = [];
7475
+ if (Array.isArray(opts.options) && opts.options.length) {
7476
+ const byHash = new Map(opts.options.map(o => [
7477
+ crypto.createHash('sha256').update(String(o), 'utf8').digest('hex'), String(o)
7478
+ ]));
7479
+ selected = selectedHashes
7480
+ .map(h => byHash.get(h.toString('hex')))
7481
+ .filter(o => o !== undefined);
7482
+ }
7483
+
7484
+ return {
7485
+ selected,
7486
+ selectedHashes,
7487
+ votedAt: d.senderTimestampMs != null ? d.senderTimestampMs : null
7488
+ };
7489
+ }
7490
+
7491
+ /**
7492
+ * Unlink this device from the account.
7493
+ *
7494
+ * The companion asks the server to remove it, the same way the Linked Devices
7495
+ * screen on the phone does, and then closes the connection. Everything on
7496
+ * disk stays where it is — the session it describes no longer exists, so a
7497
+ * later `connectWeb()` pairs afresh rather than trying to resume something
7498
+ * the server has forgotten. Delete the session directory yourself if you want
7499
+ * the keys gone as well.
7500
+ *
7501
+ * Web only: an account that registered its own number is not a companion and
7502
+ * has nothing to unlink from.
7503
+ *
7504
+ * @returns {Promise<boolean>} whether the server accepted the removal
7505
+ */
7506
+ async logout() {
7507
+ if (this._mode !== 'web') {
7508
+ throw new Error('logout: only a companion can unlink itself — a session ' +
7509
+ 'that registered its own number has nothing to unlink from');
7510
+ }
7511
+ if (!this._socket || !this._connected) throw new Error('Not connected');
7512
+
7513
+ const ownJid = toNonAdJid(this._ownDeviceJid() || '');
7514
+ if (!ownJid || ownJid.startsWith('@')) throw new Error('logout: not logged in');
7515
+
7516
+ let accepted = false;
7517
+ try {
7518
+ const resp = await this._sendIq(new BinaryNode('iq', {
7519
+ id: this._genMsgId(),
7520
+ to: 's.whatsapp.net',
7521
+ type: 'set',
7522
+ xmlns: 'md'
7523
+ }, [new BinaryNode('remove-companion-device', {
7524
+ jid: jidStrToObj(ownJid),
7525
+ reason: 'user_initiated'
7526
+ }, null)]));
7527
+ accepted = !!(resp && !(resp.attrs && resp.attrs.type === 'error'));
7528
+ } finally {
7529
+ // The connection goes either way. A server that refused the removal is
7530
+ // still a server we asked to be forgotten by, and staying connected on
7531
+ // that basis is worse than dropping.
7532
+ this.disconnect();
7533
+ }
7534
+ return accepted;
7535
+ }
7536
+
7293
7537
  // ─── Location ─────────────────────────────────────────────────────────────
7294
7538
  // sendLocation(to, latitude, longitude, opts)
7295
7539
  // opts: { name, address, url, id, contextInfo }
@@ -1206,57 +1206,123 @@ class DeviceManager {
1206
1206
  * and sends both hashes on the same notification — so each is verified on its
1207
1207
  * own, and one going wrong does not cost the other.
1208
1208
  *
1209
- * @param {string} cacheKey the cache entry: digits, or 'lid:<user>'
1210
- * @param {string} user the JID user part the ids belong to
1211
- * @param {Array<{tag: string, deviceId: number}>} changes
1212
- * @param {string} serverHash device_hash, or device_lid_hash
1213
- * @param {string} [server] 's.whatsapp.net' (default) or 'lid'
1214
- * @returns {boolean} whether the result verified and the cache was kept
1209
+ * The steps are applied one at a time, and each is checked against its own
1210
+ * hash: `device_hash` describes the list **after that change**, not after the
1211
+ * notification as a whole. A contact linking two devices in one sitting sends
1212
+ * two children with two different hashes, and reading them as one change with
1213
+ * one hash — which is what this did — meant the notification could never
1214
+ * verify and the cache was dropped every time. Stepping through them the way
1215
+ * whatsmeow's handleDeviceNotification does keeps the cache across exactly
1216
+ * the case that used to defeat it.
1217
+ *
1218
+ * A step that fails to verify drops the cache but not the running list, so a
1219
+ * later step whose hash does match puts a proven list back — the same
1220
+ * recovery whatsmeow gets from mutating its local copy after deleting the
1221
+ * cached one.
1222
+ *
1223
+ * @param {object} opts
1224
+ * @param {string} opts.priKey the cache entry: digits, or 'lid:<user>'
1225
+ * @param {string} opts.priUser the JID user part the ids belong to
1226
+ * @param {string} [opts.priServer] 's.whatsapp.net' (default) or 'lid'
1227
+ * @param {string} [opts.lidKey] cache entry for the LID half, when known
1228
+ * @param {string} [opts.lidUser] the LID user part
1229
+ * @param {Array} opts.steps from Client._parseDeviceDelta
1230
+ * @returns {{primaryOk: boolean, lidOk: boolean}} which halves ended verified
1215
1231
  */
1216
- verifyDeviceDelta(cacheKey, user, changes, serverHash, server) {
1217
- server = server || 's.whatsapp.net';
1218
- if (!cacheKey || !user || !serverHash) return false;
1219
- if (!Array.isArray(changes) || !changes.length) return false;
1232
+ applyDeviceDelta(opts) {
1233
+ opts = opts || {};
1234
+ const priKey = opts.priKey;
1235
+ const priUser = opts.priUser;
1236
+ const priServer = opts.priServer || 's.whatsapp.net';
1237
+ const steps = opts.steps;
1238
+ const out = { primaryOk: false, lidOk: false };
1239
+
1240
+ if (!priKey || !priUser) return out;
1241
+ if (!Array.isArray(steps) || !steps.length) return out;
1220
1242
 
1221
1243
  // No baseline means there is nothing to apply a change to. Caching just the
1222
1244
  // one device named in the notification would make a list of one look
1223
1245
  // authoritative, which is how a recipient's other devices stop being
1224
1246
  // written to.
1225
- const cached = this._dcGet(cacheKey);
1226
- if (!cached || !cached.length) return false;
1227
-
1228
- const next = new Set(cached);
1229
- for (const { tag, deviceId } of changes) {
1230
- if (!Number.isFinite(deviceId)) return false;
1231
- if (tag === 'add') next.add(deviceId);
1232
- else if (tag === 'remove') next.delete(deviceId);
1233
- else return false; // 'update' and anything unknown: do not guess
1247
+ const cached = this._dcGet(priKey);
1248
+ if (!cached || !cached.length) return out;
1249
+
1250
+ const haveLid = !!(opts.lidKey && opts.lidUser);
1251
+ const pri = new Set(cached);
1252
+ // The LID half starts from whatever is on file, empty included. It cannot
1253
+ // go wrong the way the primary half could: a list built from nothing has to
1254
+ // pass the server's hash before it is written anywhere.
1255
+ const lid = haveLid ? new Set(this._dcGet(opts.lidKey) || []) : null;
1256
+
1257
+ for (const step of steps) {
1258
+ if (!step) continue;
1259
+
1260
+ // `update` does not say what changed. The list on file cannot be trusted
1261
+ // and cannot be repaired, so it goes — and the remaining steps still get
1262
+ // their turn, as they do in whatsmeow.
1263
+ if (step.tag === 'update') {
1264
+ this._dcDel([priKey]);
1265
+ out.primaryOk = false;
1266
+ _whaDbg('[DBG] DEV_HASH update for ' + priKey + ' — cache dropped');
1267
+ continue;
1268
+ }
1269
+ if (step.tag !== 'add' && step.tag !== 'remove') continue;
1270
+ if (!Number.isFinite(step.deviceId) || !step.hash) continue;
1271
+
1272
+ if (step.tag === 'add') pri.add(step.deviceId);
1273
+ else pri.delete(step.deviceId);
1274
+
1275
+ const ids = [...pri].sort((a, b) => a - b);
1276
+ const ours = this._phashOfDevices(priUser, ids, priServer);
1277
+ if (ours && ours === step.hash) {
1278
+ this._dcSet(priKey, ids);
1279
+ out.primaryOk = true;
1280
+ _whaDbg('[DBG] DEV_HASH verified for ' + priKey + ' ids=[' + ids.join(',') +
1281
+ '] hash=' + ours);
1282
+ } else {
1283
+ this._dcDel([priKey]);
1284
+ out.primaryOk = false;
1285
+ _whaDbg('[DBG] DEV_HASH mismatch for ' + priKey + ' ours=' + ours +
1286
+ ' server=' + step.hash + ' — dropping cache');
1287
+ }
1288
+
1289
+ // The LID half of the same change, checked on its own. The server hashes
1290
+ // the two lists separately and one going wrong does not cost the other.
1291
+ if (!haveLid || step.lidDeviceId === null || !step.lidHash) continue;
1292
+
1293
+ if (step.tag === 'add') lid.add(step.lidDeviceId);
1294
+ else lid.delete(step.lidDeviceId);
1295
+
1296
+ const lidIds = [...lid].sort((a, b) => a - b);
1297
+ const lidOurs = this._phashOfDevices(opts.lidUser, lidIds, 'lid');
1298
+ if (lidOurs && lidOurs === step.lidHash) {
1299
+ this._dcSet(opts.lidKey, lidIds);
1300
+ out.lidOk = true;
1301
+ _whaDbg('[DBG] DEV_HASH verified for ' + opts.lidKey + ' ids=[' +
1302
+ lidIds.join(',') + '] hash=' + lidOurs);
1303
+ } else {
1304
+ this._dcDel([opts.lidKey]);
1305
+ out.lidOk = false;
1306
+ _whaDbg('[DBG] DEV_HASH mismatch for ' + opts.lidKey + ' ours=' + lidOurs +
1307
+ ' server=' + step.lidHash + ' — dropping cache');
1308
+ }
1234
1309
  }
1235
1310
 
1236
- const ids = [...next].sort((a, b) => a - b);
1237
- const jids = ids.map(d => makeDeviceJid(user, d, server));
1311
+ return out;
1312
+ }
1238
1313
 
1239
- let ours;
1314
+ // The participant hash of one user's device ids, or null when it cannot be
1315
+ // computed. Same function the group fan-out and the server both use, which is
1316
+ // the whole reason the comparison above means anything.
1317
+ _phashOfDevices(user, deviceIds, server) {
1240
1318
  try {
1241
1319
  // Lazily required: MessageSender requires this file, so taking it at the
1242
1320
  // top would close the cycle before either module finishes loading.
1243
1321
  const { computePhash } = require('./messages/MessageSender');
1244
- ours = computePhash(jids);
1322
+ return computePhash(deviceIds.map(d => makeDeviceJid(user, d, server)));
1245
1323
  } catch (_) {
1246
- return false;
1247
- }
1248
-
1249
- if (ours !== String(serverHash)) {
1250
- _whaDbg('[DBG] DEV_HASH mismatch for ' + cacheKey + ' ours=' + ours +
1251
- ' server=' + serverHash + ' — dropping cache');
1252
- this._dcDel([cacheKey]);
1253
- return false;
1324
+ return null;
1254
1325
  }
1255
-
1256
- this._dcSet(cacheKey, ids);
1257
- _whaDbg('[DBG] DEV_HASH verified for ' + cacheKey + ' ids=[' + ids.join(',') +
1258
- '] hash=' + ours);
1259
- return true;
1260
1326
  }
1261
1327
 
1262
1328
  clearCache(phones) {
@@ -229,10 +229,6 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
229
229
  });
230
230
  }
231
231
 
232
- // opts.fileEncSha256, when given, is checked against the blob as it arrived.
233
- // The MAC inside decryptMedia already proves the plaintext was not tampered
234
- // with, but this catches a truncated or substituted download before spending
235
- // the work on it, and tells the two failures apart.
236
232
  // Where a message's media actually lives. Most carry an absolute `url`, but a
237
233
  // message that arrived through history sync often has only the `directPath`,
238
234
  // which is that URL with the CDN host taken off the front.
@@ -245,19 +241,72 @@ function resolveMediaUrl(url, directPath) {
245
241
  return 'https://' + CDN_HOST + (p.startsWith('/') ? '' : '/') + p;
246
242
  }
247
243
 
244
+ // Compare a digest against what the message said it should be. Returns null
245
+ // when it matches or when there is nothing to compare against, and the reason
246
+ // when it does not.
247
+ function _digestMismatch(actual, expected, what) {
248
+ if (!expected || !expected.length) return null;
249
+ const want = Buffer.from(expected);
250
+ // timingSafeEqual throws on a length mismatch rather than returning false.
251
+ if (want.length !== actual.length) return what + ' is the wrong length';
252
+ return crypto.timingSafeEqual(actual, want) ? null : what + ' mismatch';
253
+ }
254
+
255
+ /**
256
+ * Download one media file and decrypt it.
257
+ *
258
+ * Three things are checked, and all three are checked by default — the message
259
+ * says what the file should be, so there is no reason to take the CDN's word
260
+ * for it:
261
+ *
262
+ * fileEncSha256 the blob as it arrived off the CDN
263
+ * the MAC inside decryptMedia, the one that actually authenticates
264
+ * fileSha256 the plaintext, after decrypting
265
+ *
266
+ * The MAC is the guarantee — its key comes from the mediaKey, which only the
267
+ * sender and the recipients hold — and it was always checked. The two digests
268
+ * were not: fileEncSha256 only when the caller asked for it, and fileSha256
269
+ * never at all, though it was written on every upload. whatsmeow checks both on
270
+ * every download (ErrInvalidMediaSHA256), and now so does this.
271
+ *
272
+ * They tell failures apart, too. A truncated or substituted download is caught
273
+ * before any work is spent decrypting it, and a plaintext that decrypts but is
274
+ * not the file the message described is a different fault from a corrupt blob.
275
+ *
276
+ * @param {object} [opts]
277
+ * @param {Buffer} [opts.fileEncSha256] digest of the encrypted blob
278
+ * @param {Buffer} [opts.fileSha256] digest of the decrypted file
279
+ * @param {boolean} [opts.verify=true] pass false to skip both digest checks;
280
+ * the MAC is always checked either way
281
+ */
248
282
  async function downloadMedia(url, mediaKey, keyName, opts) {
283
+ opts = opts || {};
284
+ const check = opts.verify !== false;
249
285
  const encrypted = await _httpGet(url, opts);
250
- const expected = opts && opts.fileEncSha256;
251
- if (expected && expected.length) {
252
- const actual = crypto.createHash('sha256').update(encrypted).digest();
253
- const want = Buffer.from(expected);
254
- // timingSafeEqual throws on a length mismatch rather than returning false.
255
- if (want.length !== actual.length || !crypto.timingSafeEqual(actual, want)) {
256
- throw new Error('downloadMedia: the downloaded file does not match the message ' +
257
- '(fileEncSha256 mismatch)');
286
+
287
+ if (check) {
288
+ const bad = _digestMismatch(
289
+ crypto.createHash('sha256').update(encrypted).digest(),
290
+ opts.fileEncSha256, 'fileEncSha256');
291
+ if (bad) {
292
+ throw new Error('downloadMedia: the downloaded file does not match the ' +
293
+ 'message (' + bad + ')');
294
+ }
295
+ }
296
+
297
+ const plaintext = decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
298
+
299
+ if (check) {
300
+ const bad = _digestMismatch(
301
+ crypto.createHash('sha256').update(plaintext).digest(),
302
+ opts.fileSha256, 'fileSha256');
303
+ if (bad) {
304
+ throw new Error('downloadMedia: the decrypted file is not the one the ' +
305
+ 'message described (' + bad + ')');
258
306
  }
259
307
  }
260
- return decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
308
+
309
+ return plaintext;
261
310
  }
262
311
 
263
312
  // The CDN applies the same Origin check on the way down as on the way up, so a
@@ -307,11 +356,26 @@ function _httpGet(url, opts, depth) {
307
356
  });
308
357
  }
309
358
 
359
+ // A thumbnail is encrypted under a key name of its own, not under the key name
360
+ // of the message that carries it. The one in use is the link-preview thumbnail
361
+ // on an extendedTextMessage — it has its own directPath and its own digests
362
+ // beside the message's, and reading it with 'WhatsApp Image Keys' produces a
363
+ // MAC failure and nothing else. There is no upload path because nothing here
364
+ // uploads one; they arrive with a link preview the sender built.
365
+ const THUMBNAIL_KEY_NAME = {
366
+ extendedText: 'WhatsApp Link Thumbnail Keys',
367
+ text: 'WhatsApp Link Thumbnail Keys'
368
+ };
369
+
310
370
  function getMediaKeyName(mediaType) {
311
371
  const typeInfo = MEDIA_PATH[mediaType];
312
372
  return typeInfo ? typeInfo.keyName : null;
313
373
  }
314
374
 
375
+ function getThumbnailKeyName(mediaType) {
376
+ return THUMBNAIL_KEY_NAME[mediaType] || null;
377
+ }
378
+
315
379
  module.exports = {
316
380
  MEDIA_PATH,
317
381
  encryptMedia,
@@ -323,5 +387,6 @@ module.exports = {
323
387
  httpGetBuffer: _httpGet,
324
388
  deriveMediaKeyData,
325
389
  getMediaKeyName,
390
+ getThumbnailKeyName,
326
391
  resolveMediaUrl
327
392
  };
@@ -794,11 +794,47 @@ function decodeMessageContainer(buf, depth) {
794
794
  }
795
795
 
796
796
  // Field 6: extendedTextMessage — field 1 = text
797
+ //
798
+ // A text message with a link in it comes as one of these, and everything
799
+ // the sender's client built for the preview rides along: the title and
800
+ // description it scraped, and a thumbnail. The thumbnail is a media file of
801
+ // its own — its own directPath, its own digests, its own mediaKey — not a
802
+ // part of the message, which is why only the inline jpegThumbnail was ever
803
+ // reachable and the full-size one could not be fetched at all.
804
+ //
805
+ // text=1 matchedText=2 description=5 title=6 jpegThumbnail=16
806
+ // thumbnailDirectPath=19 thumbnailSha256=20 thumbnailEncSha256=21
807
+ // mediaKey=22
797
808
  if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
798
809
  try {
799
- const ext = _decodeFields(f[6]);
810
+ const ext = _decodeFields(f[6]);
800
811
  const text = _str(ext[1]);
801
- if (text) msgResult = { type: 'text', text };
812
+ if (text) {
813
+ msgResult = { type: 'text', text };
814
+
815
+ const matchedText = _str(ext[2]);
816
+ const description = _str(ext[5]);
817
+ const title = _str(ext[6]);
818
+ if (matchedText) msgResult.matchedText = matchedText;
819
+ if (description) msgResult.description = description;
820
+ if (title) msgResult.title = title;
821
+ if (Buffer.isBuffer(ext[16]) && ext[16].length) {
822
+ msgResult.jpegThumbnail = ext[16];
823
+ }
824
+
825
+ // The downloadable preview image, when the sender attached one. All
826
+ // four are needed together: without the key it cannot be decrypted,
827
+ // and without the digests it cannot be checked.
828
+ const thumbPath = _str(ext[19]);
829
+ if (thumbPath && Buffer.isBuffer(ext[22]) && ext[22].length) {
830
+ msgResult.thumbnail = {
831
+ directPath: thumbPath,
832
+ mediaKey: ext[22],
833
+ thumbnailSha256: Buffer.isBuffer(ext[20]) ? ext[20] : null,
834
+ thumbnailEncSha256: Buffer.isBuffer(ext[21]) ? ext[21] : null
835
+ };
836
+ }
837
+ }
802
838
  } catch (_) {}
803
839
  }
804
840
 
@@ -1044,9 +1080,42 @@ function decodeMessageContainer(buf, depth) {
1044
1080
  } catch (_) { msgResult = { type: 'poll', question: '', options: [] }; }
1045
1081
  }
1046
1082
 
1047
- // Field 50: pollUpdateMessage (a vote on an existing poll)
1083
+ // Field 50: pollUpdateMessage — somebody voted on a poll.
1084
+ //
1085
+ // PollUpdateMessage { pollCreationMessageKey=1, vote=2,
1086
+ // senderTimestampMs=3 }
1087
+ // MessageKey { remoteJid=1, fromMe=2, id=3 }
1088
+ // PollEncValue { encPayload=1, encIV=2 }
1089
+ //
1090
+ // The vote itself is encrypted under a key derived from the poll's message
1091
+ // secret, so the ballot cannot be read from the stanza alone — see
1092
+ // decryptPollVote. Every one of these decoded to the bare word 'pollVote'
1093
+ // and nothing else, which left no way to tell which poll had been voted on,
1094
+ // let alone what the vote was.
1048
1095
  if (!msgResult && f[50] && Buffer.isBuffer(f[50])) {
1049
1096
  msgResult = { type: 'pollVote' };
1097
+ try {
1098
+ const upd = _decodeFields(f[50]);
1099
+
1100
+ if (Buffer.isBuffer(upd[1])) {
1101
+ const key = _decodeFields(upd[1]);
1102
+ msgResult.pollKey = {
1103
+ remoteJid: _str(key[1]) || null,
1104
+ fromMe: !!key[2],
1105
+ id: _str(key[3]) || null
1106
+ };
1107
+ }
1108
+
1109
+ if (Buffer.isBuffer(upd[2])) {
1110
+ const vote = _decodeFields(upd[2]);
1111
+ if (Buffer.isBuffer(vote[1]) && Buffer.isBuffer(vote[2])) {
1112
+ msgResult.encPayload = vote[1];
1113
+ msgResult.encIv = vote[2];
1114
+ }
1115
+ }
1116
+
1117
+ if (typeof upd[3] === 'number') msgResult.senderTimestampMs = upd[3];
1118
+ } catch (_) { /* the bare type is still worth reporting */ }
1050
1119
  }
1051
1120
 
1052
1121
  // If we found a real message, attach skdmInfo / messageSecret and return.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.29.2",
3
+ "version": "5.29.3",
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",