zapo-js 1.8.0 → 1.8.2
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/dist/client/WaClient.js +2 -1
- package/dist/client/coordinators/WaOfflineResumeCoordinator.d.ts +22 -0
- package/dist/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -1
- package/dist/client/persistence/group-history.js +4 -1
- package/dist/client/persistence/history-sync.d.ts +2 -0
- package/dist/client/persistence/history-sync.js +33 -19
- package/dist/client/types.d.ts +12 -2
- package/dist/esm/client/WaClient.js +2 -1
- package/dist/esm/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -1
- package/dist/esm/client/persistence/group-history.js +4 -1
- package/dist/esm/client/persistence/history-sync.js +34 -20
- package/dist/esm/media/constants.js +6 -0
- package/dist/esm/media/container.js +105 -0
- package/dist/esm/media/crypto/WaMediaCrypto.js +9 -2
- package/dist/esm/message/crypto/phash.js +65 -20
- package/dist/esm/message/primitives/incoming.js +27 -3
- package/dist/media/constants.d.ts +6 -0
- package/dist/media/constants.js +7 -1
- package/dist/media/container.d.ts +43 -0
- package/dist/media/container.js +110 -0
- package/dist/media/crypto/WaMediaCrypto.js +9 -2
- package/dist/message/crypto/phash.d.ts +12 -0
- package/dist/message/crypto/phash.js +65 -20
- package/dist/message/primitives/incoming.d.ts +17 -0
- package/dist/message/primitives/incoming.js +28 -3
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CONTAINER_SCAN_MAX_BYTES } from './constants.js';
|
|
2
|
+
const BOX_HEADER_SIZE = 8;
|
|
3
|
+
const LARGE_BOX_HEADER_SIZE = 16;
|
|
4
|
+
const LARGE_BOX_SIZE_MARKER = 1;
|
|
5
|
+
const BOX_TYPE_FTYP = 0x66747970;
|
|
6
|
+
const BOX_TYPE_MOOV = 0x6d6f6f76;
|
|
7
|
+
const BOX_TYPE_MDAT = 0x6d646174;
|
|
8
|
+
function readU32(bytes, offset) {
|
|
9
|
+
return (((bytes[offset] << 24) |
|
|
10
|
+
(bytes[offset + 1] << 16) |
|
|
11
|
+
(bytes[offset + 2] << 8) |
|
|
12
|
+
bytes[offset + 3]) >>>
|
|
13
|
+
0);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Decides whether a media payload supports progressive playback, by walking
|
|
17
|
+
* the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
|
|
18
|
+
* leading bytes.
|
|
19
|
+
*
|
|
20
|
+
* An ISO-BMFF file is streamable only when its `moov` index box precedes the
|
|
21
|
+
* `mdat` payload box. With `mdat` first, a player has to reach the end of the
|
|
22
|
+
* download before it can start decoding, so advertising the file as seekable
|
|
23
|
+
* (by attaching a streaming sidecar) makes the recipient fail to open it
|
|
24
|
+
* instead of falling back to a plain download.
|
|
25
|
+
*
|
|
26
|
+
* Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
|
|
27
|
+
* reported as streamable: those containers carry their index inline and have
|
|
28
|
+
* no equivalent failure mode.
|
|
29
|
+
*
|
|
30
|
+
* @param head - Leading bytes of the payload, starting at offset 0.
|
|
31
|
+
* @returns `true` when streamable, `false` when not, `null` when `head` is
|
|
32
|
+
* too short to tell and the caller should retry with more bytes.
|
|
33
|
+
*/
|
|
34
|
+
export function scanStreamable(head) {
|
|
35
|
+
if (head.byteLength < BOX_HEADER_SIZE) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (readU32(head, 4) !== BOX_TYPE_FTYP) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
let offset = 0;
|
|
42
|
+
while (offset + BOX_HEADER_SIZE <= head.byteLength) {
|
|
43
|
+
const type = readU32(head, offset + 4);
|
|
44
|
+
if (type === BOX_TYPE_MOOV)
|
|
45
|
+
return true;
|
|
46
|
+
if (type === BOX_TYPE_MDAT)
|
|
47
|
+
return false;
|
|
48
|
+
let size = readU32(head, offset);
|
|
49
|
+
let headerSize = BOX_HEADER_SIZE;
|
|
50
|
+
if (size === LARGE_BOX_SIZE_MARKER) {
|
|
51
|
+
if (offset + LARGE_BOX_HEADER_SIZE > head.byteLength)
|
|
52
|
+
return null;
|
|
53
|
+
if (readU32(head, offset + BOX_HEADER_SIZE) !== 0)
|
|
54
|
+
return true;
|
|
55
|
+
size = readU32(head, offset + BOX_HEADER_SIZE + 4);
|
|
56
|
+
headerSize = LARGE_BOX_HEADER_SIZE;
|
|
57
|
+
}
|
|
58
|
+
else if (size === 0) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (size < headerSize)
|
|
62
|
+
return true;
|
|
63
|
+
offset += size;
|
|
64
|
+
if (offset > CONTAINER_SCAN_MAX_BYTES)
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return offset > CONTAINER_SCAN_MAX_BYTES ? true : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Incremental front-end for {@link scanStreamable}, for callers that see the
|
|
71
|
+
* payload as a sequence of chunks. Buffers at most
|
|
72
|
+
* {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
|
|
73
|
+
* that budget is spent without a verdict.
|
|
74
|
+
*/
|
|
75
|
+
export class WaStreamableScanner {
|
|
76
|
+
constructor() {
|
|
77
|
+
this.head = new Uint8Array(CONTAINER_SCAN_MAX_BYTES);
|
|
78
|
+
this.headLength = 0;
|
|
79
|
+
this.settled = null;
|
|
80
|
+
}
|
|
81
|
+
/** Verdict so far; `null` until enough leading bytes have been seen. */
|
|
82
|
+
get verdict() {
|
|
83
|
+
return this.settled;
|
|
84
|
+
}
|
|
85
|
+
/** Feeds the next payload chunk. Ignored once the verdict has settled. */
|
|
86
|
+
push(chunk) {
|
|
87
|
+
if (this.settled !== null || chunk.byteLength === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const room = this.head.byteLength - this.headLength;
|
|
91
|
+
if (room > 0) {
|
|
92
|
+
const copied = Math.min(room, chunk.byteLength);
|
|
93
|
+
this.head.set(chunk.subarray(0, copied), this.headLength);
|
|
94
|
+
this.headLength += copied;
|
|
95
|
+
}
|
|
96
|
+
this.settled = scanStreamable(this.head.subarray(0, this.headLength));
|
|
97
|
+
if (this.settled === null && this.headLength === this.head.byteLength) {
|
|
98
|
+
this.settled = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Verdict to use once the payload has ended, defaulting to streamable. */
|
|
102
|
+
finish() {
|
|
103
|
+
return this.settled ?? true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -8,6 +8,7 @@ import { hkdf } from '../../crypto/core/hkdf.js';
|
|
|
8
8
|
import { aesCbcDecrypt, aesCbcEncryptWithTrailer, hmacSha256Sign, sha256 } from '../../crypto/core/primitives.js';
|
|
9
9
|
import { randomBytesAsync } from '../../crypto/core/random.js';
|
|
10
10
|
import { ENC_KEY_END, ENC_KEY_START, HMAC_TRUNCATED_SIZE, IV_SIZE, MAC_KEY_END, MAC_KEY_START, MEDIA_HKDF_SIZE, SIDECAR_CHUNK_SIZE, SIDECAR_HMAC_SIZE } from '../constants.js';
|
|
11
|
+
import { scanStreamable, WaStreamableScanner } from '../container.js';
|
|
11
12
|
import { getWaMediaHkdfInfo, WA_APP_STATE_KEY_TYPES } from '../../protocol/constants.js';
|
|
12
13
|
import { assertByteLength, toBytesView, toChunkBytes, uint8TimingSafeEqual } from '../../util/bytes.js';
|
|
13
14
|
import { toError } from '../../util/primitives.js';
|
|
@@ -105,7 +106,7 @@ export class WaMediaCrypto {
|
|
|
105
106
|
const signature = mac.subarray(0, HMAC_TRUNCATED_SIZE);
|
|
106
107
|
ciphertextHmac.set(signature, cipherLength);
|
|
107
108
|
let streamingSidecar;
|
|
108
|
-
if (options?.sidecar !== false) {
|
|
109
|
+
if (options?.sidecar !== false && scanStreamable(plaintext) !== false) {
|
|
109
110
|
const acc = new SidecarAccumulator(keys.macKey, plaintext.byteLength);
|
|
110
111
|
acc.push(keys.iv);
|
|
111
112
|
acc.push(ciphertext);
|
|
@@ -219,7 +220,8 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
|
|
|
219
220
|
const plainHash = createHash('sha256');
|
|
220
221
|
const encHash = createHash('sha256');
|
|
221
222
|
const hmac = createHmac('sha256', keys.macKey);
|
|
222
|
-
|
|
223
|
+
let sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
|
|
224
|
+
const scanner = computeSidecar ? new WaStreamableScanner() : null;
|
|
223
225
|
const ffTarget = firstFrameLength !== undefined
|
|
224
226
|
? IV_SIZE + Math.ceil(firstFrameLength / AES_BLOCK_SIZE) * AES_BLOCK_SIZE
|
|
225
227
|
: 0;
|
|
@@ -249,6 +251,11 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
|
|
|
249
251
|
continue;
|
|
250
252
|
plaintextLength += plainChunk.byteLength;
|
|
251
253
|
plainHash.update(plainChunk);
|
|
254
|
+
if (scanner !== null && scanner.verdict === null) {
|
|
255
|
+
scanner.push(plainChunk);
|
|
256
|
+
if (scanner.verdict === false)
|
|
257
|
+
sidecar = null;
|
|
258
|
+
}
|
|
252
259
|
await consumeCiphertext(cipher.update(plainChunk));
|
|
253
260
|
}
|
|
254
261
|
await consumeCiphertext(cipher.final());
|
|
@@ -9,39 +9,84 @@ const CHAR_AT = 0x40;
|
|
|
9
9
|
const CHAR_C = 0x63;
|
|
10
10
|
const CHAR_U = 0x75;
|
|
11
11
|
const CHAR_S = 0x73;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Upper bound on the bytes the canonical rewrite can add to a single jid: the
|
|
14
|
+
* `.0:` agent marker plus a device digit when the input carries neither, plus
|
|
15
|
+
* the `c.us` server substitution.
|
|
16
|
+
*/
|
|
17
|
+
const CANONICAL_GROWTH_BYTES = 4 + WA_DEFAULTS.HOST_DOMAIN.length;
|
|
18
|
+
/**
|
|
19
|
+
* Largest canonical buffer kept alive between calls (the index arrays scale
|
|
20
|
+
* with it). Lists above this bound are rare enough that a throwaway allocation
|
|
21
|
+
* beats holding the memory for the life of the process.
|
|
22
|
+
*/
|
|
23
|
+
const RETAINED_SCRATCH_BYTES = 1024 * 1024;
|
|
24
|
+
const RETAINED = {
|
|
25
|
+
scratch: new Uint8Array(0),
|
|
26
|
+
offsets: new Uint32Array(0),
|
|
27
|
+
order: new Uint32Array(0)
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Computes the v2 participant hash (`2:<base64>`) attached to group and
|
|
31
|
+
* broadcast-list fanouts.
|
|
32
|
+
*
|
|
33
|
+
* Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
|
|
34
|
+
* is sorted bytewise and hashed as a single SHA-256 stream. There is no
|
|
35
|
+
* participant ceiling: the canonical buffer grows to fit the list, so a group
|
|
36
|
+
* whose members resolve to tens of thousands of devices still hashes.
|
|
37
|
+
*
|
|
38
|
+
* @param participants device jids, in any order
|
|
39
|
+
* @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
|
|
40
|
+
*/
|
|
17
41
|
export function computePhashV2(participants) {
|
|
18
|
-
if (participants.length === 0)
|
|
19
|
-
return '2:';
|
|
20
42
|
const n = participants.length;
|
|
21
|
-
if (n
|
|
22
|
-
|
|
43
|
+
if (n === 0)
|
|
44
|
+
return '2:';
|
|
45
|
+
let requiredBytes = 0;
|
|
46
|
+
for (let i = 0; i < n; i += 1) {
|
|
47
|
+
requiredBytes += participants[i].length + CANONICAL_GROWTH_BYTES;
|
|
23
48
|
}
|
|
49
|
+
const { scratch, offsets, order } = acquireBuffers(n, requiredBytes);
|
|
24
50
|
let off = 0;
|
|
25
51
|
for (let i = 0; i < n; i += 1) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
off
|
|
52
|
+
offsets[i] = off;
|
|
53
|
+
off = writeCanonicalUtf8(scratch, off, participants[i]);
|
|
54
|
+
}
|
|
55
|
+
offsets[n] = off;
|
|
56
|
+
if (off > scratch.length) {
|
|
57
|
+
throw new Error(`phash canonical buffer overflow: needs ${off} bytes, scratch is ${scratch.length}`);
|
|
32
58
|
}
|
|
33
|
-
OFFSETS[n] = off;
|
|
34
59
|
for (let i = 0; i < n; i += 1)
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
order[i] = i;
|
|
61
|
+
const ranked = order.subarray(0, n);
|
|
62
|
+
ranked.sort((a, b) => compareScratchSlice(scratch, offsets, a, b));
|
|
37
63
|
const parts = new Array(n);
|
|
38
64
|
for (let i = 0; i < n; i += 1) {
|
|
39
|
-
const idx =
|
|
40
|
-
parts[i] =
|
|
65
|
+
const idx = ranked[i];
|
|
66
|
+
parts[i] = scratch.subarray(offsets[idx], offsets[idx + 1]);
|
|
41
67
|
}
|
|
42
68
|
const digest = sha256(parts);
|
|
43
69
|
return `2:${bytesToBase64(digest.subarray(0, PHASH_DIGEST_PREFIX))}`;
|
|
44
70
|
}
|
|
71
|
+
function acquireBuffers(participantCount, requiredBytes) {
|
|
72
|
+
if (requiredBytes > RETAINED_SCRATCH_BYTES) {
|
|
73
|
+
return {
|
|
74
|
+
scratch: new Uint8Array(requiredBytes),
|
|
75
|
+
offsets: new Uint32Array(participantCount + 1),
|
|
76
|
+
order: new Uint32Array(participantCount)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (RETAINED.scratch.length < requiredBytes) {
|
|
80
|
+
const nextBytes = Math.max(requiredBytes, RETAINED.scratch.length * 2);
|
|
81
|
+
RETAINED.scratch = new Uint8Array(Math.min(RETAINED_SCRATCH_BYTES, nextBytes));
|
|
82
|
+
}
|
|
83
|
+
if (RETAINED.order.length < participantCount) {
|
|
84
|
+
const capacity = Math.max(participantCount, RETAINED.order.length * 2);
|
|
85
|
+
RETAINED.offsets = new Uint32Array(capacity + 1);
|
|
86
|
+
RETAINED.order = new Uint32Array(capacity);
|
|
87
|
+
}
|
|
88
|
+
return RETAINED;
|
|
89
|
+
}
|
|
45
90
|
function writeCanonicalUtf8(out, start, jid) {
|
|
46
91
|
const atIndex = jid.indexOf('@');
|
|
47
92
|
if (atIndex < 1 || atIndex >= jid.length - 1) {
|
|
@@ -125,6 +125,32 @@ function buildIncomingEventRawNode(node) {
|
|
|
125
125
|
content: children
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolves who authored a {@link proto.IWebMessageInfo}. The top-level
|
|
130
|
+
* `participant` wins over `key.participant` - history sync carries the group
|
|
131
|
+
* sender in the former and leaves the latter empty on a large share of records.
|
|
132
|
+
* A self-sent group/broadcast message with neither falls back to this account,
|
|
133
|
+
* preferring `originalSelfAuthorUserJidString` over `meJid` (the current account
|
|
134
|
+
* user JID) so a message authored under a previous identity keeps its author.
|
|
135
|
+
*
|
|
136
|
+
* The participant fields are read whatever the thread type is, matching how
|
|
137
|
+
* WhatsApp Web builds the author: only the self-sent fallback is gated on
|
|
138
|
+
* group/broadcast. A 1:1 record normally carries neither, so this returns
|
|
139
|
+
* `undefined` there and the caller supplies the thread JID instead.
|
|
140
|
+
*
|
|
141
|
+
* `threadJid` overrides `key.remoteJid` for carriers that know the thread out
|
|
142
|
+
* of band, such as a group history bundle whose records may omit it.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveWebMessageInfoAuthor(webMessageInfo, meJid, threadJid) {
|
|
145
|
+
const key = webMessageInfo.key ?? {};
|
|
146
|
+
const chatJid = threadJid ?? key.remoteJid ?? undefined;
|
|
147
|
+
const isGroupOrBroadcast = chatJid ? isGroupJid(chatJid) || isBroadcastJid(chatJid) : false;
|
|
148
|
+
const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
|
|
149
|
+
const selfAuthor = key.fromMe === true && isGroupOrBroadcast && rawSelfAuthor
|
|
150
|
+
? toUserJid(rawSelfAuthor)
|
|
151
|
+
: undefined;
|
|
152
|
+
return webMessageInfo.participant ?? key.participant ?? selfAuthor ?? undefined;
|
|
153
|
+
}
|
|
128
154
|
/**
|
|
129
155
|
* Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
|
|
130
156
|
* (placeholder resend). `meJid` is the current account user JID, used as the
|
|
@@ -137,9 +163,7 @@ export function buildRecoveredIncomingEvent(webMessageInfo, meJid) {
|
|
|
137
163
|
const fromMe = key.fromMe === true;
|
|
138
164
|
const isGroup = chatJid ? isGroupJid(chatJid) : false;
|
|
139
165
|
const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
|
|
140
|
-
const
|
|
141
|
-
const selfAuthor = fromMe && (isGroup || isBroadcast) && rawSelfAuthor ? toUserJid(rawSelfAuthor) : undefined;
|
|
142
|
-
const participant = webMessageInfo.participant ?? key.participant ?? selfAuthor;
|
|
166
|
+
const participant = resolveWebMessageInfoAuthor(webMessageInfo, meJid);
|
|
143
167
|
const pushName = webMessageInfo.pushName ?? undefined;
|
|
144
168
|
const rawSender = fromMe ? undefined : isGroup || isBroadcast ? participant : chatJid;
|
|
145
169
|
const sender = rawSender ? parseJidFull(rawSender) : null;
|
|
@@ -9,6 +9,12 @@ export declare const MAC_KEY_END = 80;
|
|
|
9
9
|
export declare const HMAC_TRUNCATED_SIZE = 10;
|
|
10
10
|
export declare const SIDECAR_CHUNK_SIZE = 65536;
|
|
11
11
|
export declare const SIDECAR_HMAC_SIZE = 10;
|
|
12
|
+
/**
|
|
13
|
+
* Leading bytes buffered when deciding whether a payload supports progressive
|
|
14
|
+
* playback. The top-level boxes that settle it sit at the very front of the
|
|
15
|
+
* file, so this only has to survive a few small boxes before `moov`/`mdat`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CONTAINER_SCAN_MAX_BYTES = 1024;
|
|
12
18
|
export type MediaUploadKind = 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'gif' | 'ptt' | 'ptv' | 'ads-image' | 'ads-video' | 'group-history' | 'md-app-state' | 'md-msg-hist' | 'music-artwork' | 'newsletter-music-artwork' | 'sticker-pack' | 'thumbnail-document' | 'thumbnail-image' | 'thumbnail-link' | 'thumbnail-sticker-pack' | 'thumbnail-video' | 'waffle-image' | 'waffle-video';
|
|
13
19
|
export declare const MEDIA_UPLOAD_PATHS: Readonly<Record<MediaUploadKind, string>>;
|
|
14
20
|
export type NewsletterMediaKind = 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'sticker-pack' | 'gif' | 'ptt' | 'ptv' | 'thumbnail-link';
|
package/dist/media/constants.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NEWSLETTER_MEDIA_UPLOAD_PATHS = exports.PPS_UPLOAD_PATHS = exports.MEDIA_UPLOAD_PATHS = exports.SIDECAR_HMAC_SIZE = exports.SIDECAR_CHUNK_SIZE = exports.HMAC_TRUNCATED_SIZE = exports.MAC_KEY_END = exports.MAC_KEY_START = exports.ENC_KEY_END = exports.ENC_KEY_START = exports.IV_SIZE = exports.MEDIA_HKDF_SIZE = exports.MEDIA_CONN_CACHE_GRACE_MS = exports.DEFAULT_MEDIA_HOSTS = void 0;
|
|
3
|
+
exports.NEWSLETTER_MEDIA_UPLOAD_PATHS = exports.PPS_UPLOAD_PATHS = exports.MEDIA_UPLOAD_PATHS = exports.CONTAINER_SCAN_MAX_BYTES = exports.SIDECAR_HMAC_SIZE = exports.SIDECAR_CHUNK_SIZE = exports.HMAC_TRUNCATED_SIZE = exports.MAC_KEY_END = exports.MAC_KEY_START = exports.ENC_KEY_END = exports.ENC_KEY_START = exports.IV_SIZE = exports.MEDIA_HKDF_SIZE = exports.MEDIA_CONN_CACHE_GRACE_MS = exports.DEFAULT_MEDIA_HOSTS = void 0;
|
|
4
4
|
exports.DEFAULT_MEDIA_HOSTS = Object.freeze([
|
|
5
5
|
'mmg.whatsapp.net',
|
|
6
6
|
'mmg-fallback.whatsapp.net'
|
|
@@ -15,6 +15,12 @@ exports.MAC_KEY_END = 80;
|
|
|
15
15
|
exports.HMAC_TRUNCATED_SIZE = 10;
|
|
16
16
|
exports.SIDECAR_CHUNK_SIZE = 65536;
|
|
17
17
|
exports.SIDECAR_HMAC_SIZE = 10;
|
|
18
|
+
/**
|
|
19
|
+
* Leading bytes buffered when deciding whether a payload supports progressive
|
|
20
|
+
* playback. The top-level boxes that settle it sit at the very front of the
|
|
21
|
+
* file, so this only has to survive a few small boxes before `moov`/`mdat`.
|
|
22
|
+
*/
|
|
23
|
+
exports.CONTAINER_SCAN_MAX_BYTES = 1024;
|
|
18
24
|
exports.MEDIA_UPLOAD_PATHS = Object.freeze({
|
|
19
25
|
image: '/mms/image',
|
|
20
26
|
video: '/mms/video',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict of {@link scanStreamable}: `true` when the payload can be played
|
|
3
|
+
* back while it downloads, `false` when it cannot, `null` while the leading
|
|
4
|
+
* bytes seen so far are not enough to decide.
|
|
5
|
+
*/
|
|
6
|
+
export type WaStreamableVerdict = boolean | null;
|
|
7
|
+
/**
|
|
8
|
+
* Decides whether a media payload supports progressive playback, by walking
|
|
9
|
+
* the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
|
|
10
|
+
* leading bytes.
|
|
11
|
+
*
|
|
12
|
+
* An ISO-BMFF file is streamable only when its `moov` index box precedes the
|
|
13
|
+
* `mdat` payload box. With `mdat` first, a player has to reach the end of the
|
|
14
|
+
* download before it can start decoding, so advertising the file as seekable
|
|
15
|
+
* (by attaching a streaming sidecar) makes the recipient fail to open it
|
|
16
|
+
* instead of falling back to a plain download.
|
|
17
|
+
*
|
|
18
|
+
* Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
|
|
19
|
+
* reported as streamable: those containers carry their index inline and have
|
|
20
|
+
* no equivalent failure mode.
|
|
21
|
+
*
|
|
22
|
+
* @param head - Leading bytes of the payload, starting at offset 0.
|
|
23
|
+
* @returns `true` when streamable, `false` when not, `null` when `head` is
|
|
24
|
+
* too short to tell and the caller should retry with more bytes.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scanStreamable(head: Uint8Array): WaStreamableVerdict;
|
|
27
|
+
/**
|
|
28
|
+
* Incremental front-end for {@link scanStreamable}, for callers that see the
|
|
29
|
+
* payload as a sequence of chunks. Buffers at most
|
|
30
|
+
* {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
|
|
31
|
+
* that budget is spent without a verdict.
|
|
32
|
+
*/
|
|
33
|
+
export declare class WaStreamableScanner {
|
|
34
|
+
private readonly head;
|
|
35
|
+
private headLength;
|
|
36
|
+
private settled;
|
|
37
|
+
/** Verdict so far; `null` until enough leading bytes have been seen. */
|
|
38
|
+
get verdict(): WaStreamableVerdict;
|
|
39
|
+
/** Feeds the next payload chunk. Ignored once the verdict has settled. */
|
|
40
|
+
push(chunk: Uint8Array): void;
|
|
41
|
+
/** Verdict to use once the payload has ended, defaulting to streamable. */
|
|
42
|
+
finish(): boolean;
|
|
43
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WaStreamableScanner = void 0;
|
|
4
|
+
exports.scanStreamable = scanStreamable;
|
|
5
|
+
const constants_1 = require("./constants");
|
|
6
|
+
const BOX_HEADER_SIZE = 8;
|
|
7
|
+
const LARGE_BOX_HEADER_SIZE = 16;
|
|
8
|
+
const LARGE_BOX_SIZE_MARKER = 1;
|
|
9
|
+
const BOX_TYPE_FTYP = 0x66747970;
|
|
10
|
+
const BOX_TYPE_MOOV = 0x6d6f6f76;
|
|
11
|
+
const BOX_TYPE_MDAT = 0x6d646174;
|
|
12
|
+
function readU32(bytes, offset) {
|
|
13
|
+
return (((bytes[offset] << 24) |
|
|
14
|
+
(bytes[offset + 1] << 16) |
|
|
15
|
+
(bytes[offset + 2] << 8) |
|
|
16
|
+
bytes[offset + 3]) >>>
|
|
17
|
+
0);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Decides whether a media payload supports progressive playback, by walking
|
|
21
|
+
* the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
|
|
22
|
+
* leading bytes.
|
|
23
|
+
*
|
|
24
|
+
* An ISO-BMFF file is streamable only when its `moov` index box precedes the
|
|
25
|
+
* `mdat` payload box. With `mdat` first, a player has to reach the end of the
|
|
26
|
+
* download before it can start decoding, so advertising the file as seekable
|
|
27
|
+
* (by attaching a streaming sidecar) makes the recipient fail to open it
|
|
28
|
+
* instead of falling back to a plain download.
|
|
29
|
+
*
|
|
30
|
+
* Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
|
|
31
|
+
* reported as streamable: those containers carry their index inline and have
|
|
32
|
+
* no equivalent failure mode.
|
|
33
|
+
*
|
|
34
|
+
* @param head - Leading bytes of the payload, starting at offset 0.
|
|
35
|
+
* @returns `true` when streamable, `false` when not, `null` when `head` is
|
|
36
|
+
* too short to tell and the caller should retry with more bytes.
|
|
37
|
+
*/
|
|
38
|
+
function scanStreamable(head) {
|
|
39
|
+
if (head.byteLength < BOX_HEADER_SIZE) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (readU32(head, 4) !== BOX_TYPE_FTYP) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
let offset = 0;
|
|
46
|
+
while (offset + BOX_HEADER_SIZE <= head.byteLength) {
|
|
47
|
+
const type = readU32(head, offset + 4);
|
|
48
|
+
if (type === BOX_TYPE_MOOV)
|
|
49
|
+
return true;
|
|
50
|
+
if (type === BOX_TYPE_MDAT)
|
|
51
|
+
return false;
|
|
52
|
+
let size = readU32(head, offset);
|
|
53
|
+
let headerSize = BOX_HEADER_SIZE;
|
|
54
|
+
if (size === LARGE_BOX_SIZE_MARKER) {
|
|
55
|
+
if (offset + LARGE_BOX_HEADER_SIZE > head.byteLength)
|
|
56
|
+
return null;
|
|
57
|
+
if (readU32(head, offset + BOX_HEADER_SIZE) !== 0)
|
|
58
|
+
return true;
|
|
59
|
+
size = readU32(head, offset + BOX_HEADER_SIZE + 4);
|
|
60
|
+
headerSize = LARGE_BOX_HEADER_SIZE;
|
|
61
|
+
}
|
|
62
|
+
else if (size === 0) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (size < headerSize)
|
|
66
|
+
return true;
|
|
67
|
+
offset += size;
|
|
68
|
+
if (offset > constants_1.CONTAINER_SCAN_MAX_BYTES)
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return offset > constants_1.CONTAINER_SCAN_MAX_BYTES ? true : null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Incremental front-end for {@link scanStreamable}, for callers that see the
|
|
75
|
+
* payload as a sequence of chunks. Buffers at most
|
|
76
|
+
* {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
|
|
77
|
+
* that budget is spent without a verdict.
|
|
78
|
+
*/
|
|
79
|
+
class WaStreamableScanner {
|
|
80
|
+
constructor() {
|
|
81
|
+
this.head = new Uint8Array(constants_1.CONTAINER_SCAN_MAX_BYTES);
|
|
82
|
+
this.headLength = 0;
|
|
83
|
+
this.settled = null;
|
|
84
|
+
}
|
|
85
|
+
/** Verdict so far; `null` until enough leading bytes have been seen. */
|
|
86
|
+
get verdict() {
|
|
87
|
+
return this.settled;
|
|
88
|
+
}
|
|
89
|
+
/** Feeds the next payload chunk. Ignored once the verdict has settled. */
|
|
90
|
+
push(chunk) {
|
|
91
|
+
if (this.settled !== null || chunk.byteLength === 0) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const room = this.head.byteLength - this.headLength;
|
|
95
|
+
if (room > 0) {
|
|
96
|
+
const copied = Math.min(room, chunk.byteLength);
|
|
97
|
+
this.head.set(chunk.subarray(0, copied), this.headLength);
|
|
98
|
+
this.headLength += copied;
|
|
99
|
+
}
|
|
100
|
+
this.settled = scanStreamable(this.head.subarray(0, this.headLength));
|
|
101
|
+
if (this.settled === null && this.headLength === this.head.byteLength) {
|
|
102
|
+
this.settled = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Verdict to use once the payload has ended, defaulting to streamable. */
|
|
106
|
+
finish() {
|
|
107
|
+
return this.settled ?? true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.WaStreamableScanner = WaStreamableScanner;
|
|
@@ -11,6 +11,7 @@ const hkdf_1 = require("../../crypto/core/hkdf");
|
|
|
11
11
|
const primitives_1 = require("../../crypto/core/primitives");
|
|
12
12
|
const random_1 = require("../../crypto/core/random");
|
|
13
13
|
const constants_1 = require("../constants");
|
|
14
|
+
const container_1 = require("../container");
|
|
14
15
|
const constants_2 = require("../../protocol/constants");
|
|
15
16
|
const bytes_1 = require("../../util/bytes");
|
|
16
17
|
const primitives_2 = require("../../util/primitives");
|
|
@@ -108,7 +109,7 @@ class WaMediaCrypto {
|
|
|
108
109
|
const signature = mac.subarray(0, constants_1.HMAC_TRUNCATED_SIZE);
|
|
109
110
|
ciphertextHmac.set(signature, cipherLength);
|
|
110
111
|
let streamingSidecar;
|
|
111
|
-
if (options?.sidecar !== false) {
|
|
112
|
+
if (options?.sidecar !== false && (0, container_1.scanStreamable)(plaintext) !== false) {
|
|
112
113
|
const acc = new SidecarAccumulator(keys.macKey, plaintext.byteLength);
|
|
113
114
|
acc.push(keys.iv);
|
|
114
115
|
acc.push(ciphertext);
|
|
@@ -223,7 +224,8 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
|
|
|
223
224
|
const plainHash = (0, node_crypto_1.createHash)('sha256');
|
|
224
225
|
const encHash = (0, node_crypto_1.createHash)('sha256');
|
|
225
226
|
const hmac = (0, node_crypto_1.createHmac)('sha256', keys.macKey);
|
|
226
|
-
|
|
227
|
+
let sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
|
|
228
|
+
const scanner = computeSidecar ? new container_1.WaStreamableScanner() : null;
|
|
227
229
|
const ffTarget = firstFrameLength !== undefined
|
|
228
230
|
? constants_1.IV_SIZE + Math.ceil(firstFrameLength / AES_BLOCK_SIZE) * AES_BLOCK_SIZE
|
|
229
231
|
: 0;
|
|
@@ -253,6 +255,11 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
|
|
|
253
255
|
continue;
|
|
254
256
|
plaintextLength += plainChunk.byteLength;
|
|
255
257
|
plainHash.update(plainChunk);
|
|
258
|
+
if (scanner !== null && scanner.verdict === null) {
|
|
259
|
+
scanner.push(plainChunk);
|
|
260
|
+
if (scanner.verdict === false)
|
|
261
|
+
sidecar = null;
|
|
262
|
+
}
|
|
256
263
|
await consumeCiphertext(cipher.update(plainChunk));
|
|
257
264
|
}
|
|
258
265
|
await consumeCiphertext(cipher.final());
|
|
@@ -1 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes the v2 participant hash (`2:<base64>`) attached to group and
|
|
3
|
+
* broadcast-list fanouts.
|
|
4
|
+
*
|
|
5
|
+
* Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
|
|
6
|
+
* is sorted bytewise and hashed as a single SHA-256 stream. There is no
|
|
7
|
+
* participant ceiling: the canonical buffer grows to fit the list, so a group
|
|
8
|
+
* whose members resolve to tens of thousands of devices still hashes.
|
|
9
|
+
*
|
|
10
|
+
* @param participants device jids, in any order
|
|
11
|
+
* @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
|
|
12
|
+
*/
|
|
1
13
|
export declare function computePhashV2(participants: readonly string[]): string;
|
|
@@ -12,39 +12,84 @@ const CHAR_AT = 0x40;
|
|
|
12
12
|
const CHAR_C = 0x63;
|
|
13
13
|
const CHAR_U = 0x75;
|
|
14
14
|
const CHAR_S = 0x73;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Upper bound on the bytes the canonical rewrite can add to a single jid: the
|
|
17
|
+
* `.0:` agent marker plus a device digit when the input carries neither, plus
|
|
18
|
+
* the `c.us` server substitution.
|
|
19
|
+
*/
|
|
20
|
+
const CANONICAL_GROWTH_BYTES = 4 + constants_1.WA_DEFAULTS.HOST_DOMAIN.length;
|
|
21
|
+
/**
|
|
22
|
+
* Largest canonical buffer kept alive between calls (the index arrays scale
|
|
23
|
+
* with it). Lists above this bound are rare enough that a throwaway allocation
|
|
24
|
+
* beats holding the memory for the life of the process.
|
|
25
|
+
*/
|
|
26
|
+
const RETAINED_SCRATCH_BYTES = 1024 * 1024;
|
|
27
|
+
const RETAINED = {
|
|
28
|
+
scratch: new Uint8Array(0),
|
|
29
|
+
offsets: new Uint32Array(0),
|
|
30
|
+
order: new Uint32Array(0)
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Computes the v2 participant hash (`2:<base64>`) attached to group and
|
|
34
|
+
* broadcast-list fanouts.
|
|
35
|
+
*
|
|
36
|
+
* Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
|
|
37
|
+
* is sorted bytewise and hashed as a single SHA-256 stream. There is no
|
|
38
|
+
* participant ceiling: the canonical buffer grows to fit the list, so a group
|
|
39
|
+
* whose members resolve to tens of thousands of devices still hashes.
|
|
40
|
+
*
|
|
41
|
+
* @param participants device jids, in any order
|
|
42
|
+
* @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
|
|
43
|
+
*/
|
|
20
44
|
function computePhashV2(participants) {
|
|
21
|
-
if (participants.length === 0)
|
|
22
|
-
return '2:';
|
|
23
45
|
const n = participants.length;
|
|
24
|
-
if (n
|
|
25
|
-
|
|
46
|
+
if (n === 0)
|
|
47
|
+
return '2:';
|
|
48
|
+
let requiredBytes = 0;
|
|
49
|
+
for (let i = 0; i < n; i += 1) {
|
|
50
|
+
requiredBytes += participants[i].length + CANONICAL_GROWTH_BYTES;
|
|
26
51
|
}
|
|
52
|
+
const { scratch, offsets, order } = acquireBuffers(n, requiredBytes);
|
|
27
53
|
let off = 0;
|
|
28
54
|
for (let i = 0; i < n; i += 1) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
off
|
|
55
|
+
offsets[i] = off;
|
|
56
|
+
off = writeCanonicalUtf8(scratch, off, participants[i]);
|
|
57
|
+
}
|
|
58
|
+
offsets[n] = off;
|
|
59
|
+
if (off > scratch.length) {
|
|
60
|
+
throw new Error(`phash canonical buffer overflow: needs ${off} bytes, scratch is ${scratch.length}`);
|
|
35
61
|
}
|
|
36
|
-
OFFSETS[n] = off;
|
|
37
62
|
for (let i = 0; i < n; i += 1)
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
order[i] = i;
|
|
64
|
+
const ranked = order.subarray(0, n);
|
|
65
|
+
ranked.sort((a, b) => compareScratchSlice(scratch, offsets, a, b));
|
|
40
66
|
const parts = new Array(n);
|
|
41
67
|
for (let i = 0; i < n; i += 1) {
|
|
42
|
-
const idx =
|
|
43
|
-
parts[i] =
|
|
68
|
+
const idx = ranked[i];
|
|
69
|
+
parts[i] = scratch.subarray(offsets[idx], offsets[idx + 1]);
|
|
44
70
|
}
|
|
45
71
|
const digest = (0, core_1.sha256)(parts);
|
|
46
72
|
return `2:${(0, bytes_1.bytesToBase64)(digest.subarray(0, PHASH_DIGEST_PREFIX))}`;
|
|
47
73
|
}
|
|
74
|
+
function acquireBuffers(participantCount, requiredBytes) {
|
|
75
|
+
if (requiredBytes > RETAINED_SCRATCH_BYTES) {
|
|
76
|
+
return {
|
|
77
|
+
scratch: new Uint8Array(requiredBytes),
|
|
78
|
+
offsets: new Uint32Array(participantCount + 1),
|
|
79
|
+
order: new Uint32Array(participantCount)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (RETAINED.scratch.length < requiredBytes) {
|
|
83
|
+
const nextBytes = Math.max(requiredBytes, RETAINED.scratch.length * 2);
|
|
84
|
+
RETAINED.scratch = new Uint8Array(Math.min(RETAINED_SCRATCH_BYTES, nextBytes));
|
|
85
|
+
}
|
|
86
|
+
if (RETAINED.order.length < participantCount) {
|
|
87
|
+
const capacity = Math.max(participantCount, RETAINED.order.length * 2);
|
|
88
|
+
RETAINED.offsets = new Uint32Array(capacity + 1);
|
|
89
|
+
RETAINED.order = new Uint32Array(capacity);
|
|
90
|
+
}
|
|
91
|
+
return RETAINED;
|
|
92
|
+
}
|
|
48
93
|
function writeCanonicalUtf8(out, start, jid) {
|
|
49
94
|
const atIndex = jid.indexOf('@');
|
|
50
95
|
if (atIndex < 1 || atIndex >= jid.length - 1) {
|