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.
@@ -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
- const sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
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
- const MAX_PARTICIPANTS = 2048;
13
- const PER_WID_BYTES = 96;
14
- const SCRATCH = new Uint8Array(MAX_PARTICIPANTS * PER_WID_BYTES);
15
- const OFFSETS = new Uint32Array(MAX_PARTICIPANTS + 1);
16
- const ORDER = new Uint32Array(MAX_PARTICIPANTS);
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 > MAX_PARTICIPANTS) {
22
- throw new Error(`phash participant count ${n} exceeds MAX_PARTICIPANTS ${MAX_PARTICIPANTS}`);
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
- OFFSETS[i] = off;
27
- const nextOff = writeCanonicalUtf8(SCRATCH, off, participants[i]);
28
- if (nextOff > SCRATCH.length) {
29
- throw new Error(`phash canonical buffer overflow at participant ${i}: needs ${nextOff} bytes, scratch is ${SCRATCH.length}`);
30
- }
31
- off = nextOff;
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
- ORDER[i] = i;
36
- ORDER.subarray(0, n).sort((a, b) => compareScratchSlice(SCRATCH, OFFSETS, a, b));
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 = ORDER[i];
40
- parts[i] = SCRATCH.subarray(OFFSETS[idx], OFFSETS[idx + 1]);
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 rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
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';
@@ -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
- const sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
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
- const MAX_PARTICIPANTS = 2048;
16
- const PER_WID_BYTES = 96;
17
- const SCRATCH = new Uint8Array(MAX_PARTICIPANTS * PER_WID_BYTES);
18
- const OFFSETS = new Uint32Array(MAX_PARTICIPANTS + 1);
19
- const ORDER = new Uint32Array(MAX_PARTICIPANTS);
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 > MAX_PARTICIPANTS) {
25
- throw new Error(`phash participant count ${n} exceeds MAX_PARTICIPANTS ${MAX_PARTICIPANTS}`);
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
- OFFSETS[i] = off;
30
- const nextOff = writeCanonicalUtf8(SCRATCH, off, participants[i]);
31
- if (nextOff > SCRATCH.length) {
32
- throw new Error(`phash canonical buffer overflow at participant ${i}: needs ${nextOff} bytes, scratch is ${SCRATCH.length}`);
33
- }
34
- off = nextOff;
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
- ORDER[i] = i;
39
- ORDER.subarray(0, n).sort((a, b) => compareScratchSlice(SCRATCH, OFFSETS, a, b));
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 = ORDER[i];
43
- parts[i] = SCRATCH.subarray(OFFSETS[idx], OFFSETS[idx + 1]);
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) {