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 +56 -6
- package/index.d.ts +74 -1
- package/lib/Client.js +321 -77
- package/lib/DeviceManager.js +102 -36
- package/lib/MediaService.js +78 -13
- package/lib/proto/MessageProto.js +72 -3
- package/package.json +1 -1
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
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1902
|
-
if (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
|
|
1926
|
-
* we were away. Whatever the client does
|
|
1927
|
-
* or the same announcement arrives on
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
3964
|
-
if (
|
|
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
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
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 (
|
|
3981
|
-
_whaDbg('[DBG] NOTIF_DEVICES delta verified
|
|
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
|
|
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
|
-
*
|
|
4047
|
-
*
|
|
4048
|
-
*
|
|
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
|
-
*
|
|
4051
|
-
*
|
|
4052
|
-
* this
|
|
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
|
|
4055
|
-
* beside
|
|
4056
|
-
* list. That half is optional
|
|
4057
|
-
*
|
|
4058
|
-
*
|
|
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 {
|
|
4062
|
-
*
|
|
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
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
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
|
|
4095
|
-
const
|
|
4096
|
-
if (
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
//
|
|
4100
|
-
|
|
4101
|
-
const
|
|
4102
|
-
const
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
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
|
-
|
|
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 }
|
package/lib/DeviceManager.js
CHANGED
|
@@ -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
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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(
|
|
1226
|
-
if (!cached || !cached.length) return
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1311
|
+
return out;
|
|
1312
|
+
}
|
|
1238
1313
|
|
|
1239
|
-
|
|
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
|
-
|
|
1322
|
+
return computePhash(deviceIds.map(d => makeDeviceJid(user, d, server)));
|
|
1245
1323
|
} catch (_) {
|
|
1246
|
-
return
|
|
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) {
|
package/lib/MediaService.js
CHANGED
|
@@ -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
|
-
|
|
251
|
-
if (
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
throw new Error('downloadMedia: the downloaded file does not match the
|
|
257
|
-
'(
|
|
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
|
-
|
|
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
|
|
810
|
+
const ext = _decodeFields(f[6]);
|
|
800
811
|
const text = _str(ext[1]);
|
|
801
|
-
if (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
|
|
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.
|
|
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",
|