@open-wa/decrypt 5.6.7 → 5.9.7
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/index.cjs +9 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +10 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/decrypt.test.ts +20 -3
- package/src/crypto.ts +15 -23
package/dist/index.cjs
CHANGED
|
@@ -32,29 +32,21 @@ var MissingCriticalDataError = class extends Error {
|
|
|
32
32
|
* then decrypt the ciphertext.
|
|
33
33
|
*/
|
|
34
34
|
function decryptBuffer(encryptedData, mediaKeyBase64, mediaType, expectedSize, mimetype) {
|
|
35
|
+
if (encryptedData.length < 26 || (encryptedData.length - 10) % 16 !== 0) throw new Error("Encrypted media has an invalid ciphertext or authentication tag length.");
|
|
36
|
+
const ciphertext = encryptedData.subarray(0, encryptedData.length - 10);
|
|
37
|
+
const tag = encryptedData.subarray(encryptedData.length - 10);
|
|
35
38
|
const mediaKeyBytes = Buffer.from(mediaKeyBase64, "base64");
|
|
36
39
|
const info = `WhatsApp ${mediaTypes[mediaType.toUpperCase()] ?? mediaTypes[Object.keys(mediaTypes).find((t) => mimetype?.includes(t.toLowerCase())) ?? ""]} Keys`;
|
|
37
40
|
const expanded = Buffer.from((0, node_crypto.hkdfSync)("sha256", mediaKeyBytes, Buffer.alloc(32), info, 112));
|
|
38
41
|
const iv = expanded.subarray(0, 16);
|
|
39
42
|
const cipherKey = expanded.subarray(16, 48);
|
|
43
|
+
const macKey = expanded.subarray(48, 80);
|
|
44
|
+
const expectedTag = (0, node_crypto.createHmac)("sha256", macKey).update(iv).update(ciphertext).digest().subarray(0, 10);
|
|
45
|
+
if (!(0, node_crypto.timingSafeEqual)(expectedTag, tag)) throw new Error("Encrypted media authentication failed.");
|
|
40
46
|
const decipher = (0, node_crypto.createDecipheriv)("aes-256-cbc", cipherKey, iv);
|
|
41
|
-
decipher.
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Correct PKCS#7-style padding that WhatsApp applies inconsistently.
|
|
47
|
-
*/
|
|
48
|
-
function fixPadding(data, expectedSize) {
|
|
49
|
-
const padding = 16 - expectedSize % 16 & 15;
|
|
50
|
-
if (padding > 0) {
|
|
51
|
-
if (expectedSize + padding === data.length) return data.subarray(0, data.length - padding);
|
|
52
|
-
if (data.length + padding === expectedSize) {
|
|
53
|
-
const pad = Buffer.alloc(padding, padding);
|
|
54
|
-
return Buffer.concat([data, pad]);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return data;
|
|
47
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
48
|
+
if (expectedSize !== void 0 && decrypted.length !== expectedSize) throw new Error("Decrypted media size does not match the message.");
|
|
49
|
+
return decrypted;
|
|
58
50
|
}
|
|
59
51
|
//#endregion
|
|
60
52
|
//#region src/download.ts
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["hkdfSync","createDecipheriv"],"sources":["../src/constants.ts","../src/crypto.ts","../src/download.ts","../src/decrypt.ts","../src/bleach.ts"],"sourcesContent":["/** Mapping from message type to WhatsApp's internal media-type key name. */\nexport const mediaTypes: Record<string, string> = {\n IMAGE: 'Image',\n VIDEO: 'Video',\n AUDIO: 'Audio',\n PTT: 'Audio',\n DOCUMENT: 'Document',\n STICKER: 'Image',\n} as const;\n\n/** Message types where a missing `size` field is tolerable. */\nexport const NON_SIZE_TYPES = ['sticker'] as const;\n\n/** The default user-agent used for fetching encrypted media blobs. */\nexport const DEFAULT_USER_AGENT =\n 'WhatsApp/2.16.352 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36';\n\n/** Thrown when a message is missing fields required for decryption. */\nexport class MissingCriticalDataError extends Error {\n override readonly name = 'MissingCriticalDataError';\n\n constructor(message: string) {\n super(message);\n }\n}\n","/**\n * Core crypto routines for WhatsApp media decryption.\n *\n * Uses only `node:crypto` builtins — no external HKDF library needed.\n * `crypto.hkdfSync` landed in Node 15 and is available in Bun.\n */\nimport { createDecipheriv, hkdfSync } from 'node:crypto';\n\nimport { mediaTypes } from './constants';\n\n/**\n * Derive the AES-256-CBC key and IV from the media key using HKDF-SHA256,\n * then decrypt the ciphertext.\n */\nexport function decryptBuffer(\n encryptedData: Uint8Array,\n mediaKeyBase64: string,\n mediaType: string,\n expectedSize?: number,\n mimetype?: string,\n): Buffer {\n const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');\n\n // Resolve the info string: \"WhatsApp <Type> Keys\"\n const typeKey =\n mediaTypes[mediaType.toUpperCase()] ??\n mediaTypes[\n Object.keys(mediaTypes).find((t) =>\n mimetype?.includes(t.toLowerCase()),\n ) ?? ''\n ];\n\n const info = `WhatsApp ${typeKey} Keys`;\n\n // HKDF expand to 112 bytes (iv=16 + cipherKey=32 + macKey=32 + refKey=32)\n const expanded = Buffer.from(\n hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),\n );\n\n const iv = expanded.subarray(0, 16);\n const cipherKey = expanded.subarray(16, 48);\n\n const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);\n // WhatsApp handles padding manually — disable OpenSSL auto-padding\n decipher.setAutoPadding(false);\n const decrypted = Buffer.concat([\n decipher.update(encryptedData),\n decipher.final(),\n ]);\n\n return expectedSize ? fixPadding(decrypted, expectedSize) : decrypted;\n}\n\n/**\n * Correct PKCS#7-style padding that WhatsApp applies inconsistently.\n */\nfunction fixPadding(data: Buffer, expectedSize: number): Buffer {\n const padding = (16 - (expectedSize % 16)) & 0xf;\n if (padding > 0) {\n if (expectedSize + padding === data.length) {\n // Trim trailing padding bytes\n return data.subarray(0, data.length - padding);\n }\n if (data.length + padding === expectedSize) {\n // Append missing padding\n const pad = Buffer.alloc(padding, padding);\n return Buffer.concat([data, pad]);\n }\n }\n return data;\n}\n","/**\n * Download encrypted media from WhatsApp CDN using native fetch.\n *\n * Replaces the legacy axios-based downloader.\n */\nimport {\n DEFAULT_USER_AGENT,\n} from './constants';\n\nfunction buildUserAgent(override?: string): string {\n let ua = override ?? DEFAULT_USER_AGENT;\n if (!ua.includes('WhatsApp')) ua = `WhatsApp/2.16.352 ${ua}`;\n return ua;\n}\n\n/**\n * Download an encrypted media blob from the given URL.\n * Uses the global `fetch` API (Bun native / Node 18+).\n */\nexport async function downloadEncryptedMedia(\n url: string,\n options?: { useragent?: string; maxRetries?: number; retryDelay?: number },\n): Promise<Uint8Array> {\n const { useragent, maxRetries = 3, retryDelay = 2_000 } = options ?? {};\n\n const headers: Record<string, string> = {\n 'User-Agent': buildUserAgent(useragent),\n DNT: '1',\n 'Upgrade-Insecure-Requests': '1',\n Origin: 'https://web.whatsapp.com/',\n Referer: 'https://web.whatsapp.com/',\n };\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n const res = await fetch(url, { headers });\n\n if (res.ok) {\n return new Uint8Array(await res.arrayBuffer());\n }\n\n if (res.status === 404) {\n throw new Error(\n 'This media does not exist or is no longer available on the server. ' +\n 'See: https://openwa.dev/pages/How%20to/decrypt-media.html#40439d',\n );\n }\n\n // Retry on transient errors\n if (attempt < maxRetries - 1) {\n await new Promise((r) => setTimeout(r, retryDelay));\n }\n }\n\n throw new Error(`Failed to download media after ${maxRetries} attempts`);\n}\n","/**\n * Main decryptMedia entry point.\n *\n * Downloads the encrypted blob → decrypts with AES-256-CBC via HKDF-derived keys.\n */\nimport { MissingCriticalDataError, NON_SIZE_TYPES } from './constants';\nimport { decryptBuffer } from './crypto';\nimport { downloadEncryptedMedia } from './download';\nimport type { DecryptableMessage, DecryptOptions } from './types';\n\n/**\n * Download and decrypt a WhatsApp media message.\n *\n * @param message — A message object with at least the required decryption fields.\n * @param optionsOrUa — Either a {@link DecryptOptions} object or a legacy user-agent string.\n * @returns The decrypted media as a Buffer.\n *\n * @example\n * ```ts\n * import { decryptMedia } from '@open-wa/decrypt';\n *\n * const buffer = await decryptMedia(message);\n * ```\n */\nexport async function decryptMedia(\n message: DecryptableMessage,\n optionsOrUa?: DecryptOptions | string,\n): Promise<Buffer> {\n // Normalise options — accept legacy string signature for backward compat\n const opts: DecryptOptions =\n typeof optionsOrUa === 'string'\n ? { useragent: optionsOrUa }\n : optionsOrUa ?? {};\n\n validateMessage(message);\n\n const url = (message.deprecatedMms3Url ?? message.clientUrl)!.trim();\n const encrypted = await downloadEncryptedMedia(url, opts);\n\n return decryptBuffer(\n encrypted,\n message.mediaKey,\n message.type,\n message.size,\n message.mimetype,\n );\n}\n\nfunction validateMessage(message: DecryptableMessage): void {\n const missing: string[] = [];\n\n if (!message.mediaKey) missing.push('mediaKey');\n if (!message.filehash) missing.push('filehash');\n if (!message.mimetype) missing.push('mimetype');\n if (!message.type) missing.push('type');\n if (!message.size) missing.push('size');\n\n if (missing.length === 0) return;\n\n // Size-only omission is a warning for non-sticker types, not an error\n if (missing.length === 1 && missing[0] === 'size') {\n if (!NON_SIZE_TYPES.includes(message.type as (typeof NON_SIZE_TYPES)[number])) {\n console.warn(\n '@open-wa/decrypt: size property is missing. File will fail an integrity check.',\n );\n }\n return;\n }\n\n throw new MissingCriticalDataError(\n `Message is missing critical data: ${missing.join(', ')}`,\n );\n}\n","/**\n * Strip a message down to the minimum fields needed for decryption.\n *\n * Useful for minimizing sensitive data in transit — removes sender info,\n * chat context, etc.\n */\n\nconst BLEACH_KEEP = new Set([\n 'type',\n 'clientUrl',\n 'mimetype',\n 'mediaKey',\n 'size',\n 'filehash',\n 'uploadhash',\n 'deprecatedMms3Url',\n]);\n\n/**\n * Return a copy of the message containing only the fields required for decryption.\n */\nexport function bleachMessage<T extends Record<string, unknown>>(\n message: T,\n): Partial<T> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(message)) {\n if (BLEACH_KEEP.has(key)) {\n result[key] = message[key];\n }\n }\n return result as Partial<T>;\n}\n"],"mappings":";;;;AACA,MAAa,aAAqC;CAChD,OAAO;CACP,OAAO;CACP,OAAO;CACP,KAAK;CACL,UAAU;CACV,SAAS;AACX;;AAGA,MAAa,iBAAiB,CAAC,SAAS;;AAOxC,IAAa,2BAAb,cAA8C,MAAM;CAClD,OAAyB;CAEzB,YAAY,SAAiB;EAC3B,MAAM,OAAO;CACf;AACF;;;;;;;;;;;;;ACVA,SAAgB,cACd,eACA,gBACA,WACA,cACA,UACQ;CACR,MAAM,gBAAgB,OAAO,KAAK,gBAAgB,QAAQ;CAW1D,MAAM,OAAO,YAPX,WAAW,UAAU,YAAY,MACjC,WACE,OAAO,KAAK,UAAU,CAAC,CAAC,MAAM,MAC5B,UAAU,SAAS,EAAE,YAAY,CAAC,CACpC,KAAK,IAGwB;CAGjC,MAAM,WAAW,OAAO,MAAA,GACtBA,YAAAA,SAAAA,CAAS,UAAU,eAAe,OAAO,MAAM,EAAE,GAAG,MAAM,GAAG,CAC/D;CAEA,MAAM,KAAK,SAAS,SAAS,GAAG,EAAE;CAClC,MAAM,YAAY,SAAS,SAAS,IAAI,EAAE;CAE1C,MAAM,YAAA,GAAWC,YAAAA,iBAAAA,CAAiB,eAAe,WAAW,EAAE;CAE9D,SAAS,eAAe,KAAK;CAC7B,MAAM,YAAY,OAAO,OAAO,CAC9B,SAAS,OAAO,aAAa,GAC7B,SAAS,MAAM,CACjB,CAAC;CAED,OAAO,eAAe,WAAW,WAAW,YAAY,IAAI;AAC9D;;;;AAKA,SAAS,WAAW,MAAc,cAA8B;CAC9D,MAAM,UAAW,KAAM,eAAe,KAAO;CAC7C,IAAI,UAAU,GAAG;EACf,IAAI,eAAe,YAAY,KAAK,QAElC,OAAO,KAAK,SAAS,GAAG,KAAK,SAAS,OAAO;EAE/C,IAAI,KAAK,SAAS,YAAY,cAAc;GAE1C,MAAM,MAAM,OAAO,MAAM,SAAS,OAAO;GACzC,OAAO,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC;EAClC;CACF;CACA,OAAO;AACT;;;;;;;;AC7DA,SAAS,eAAe,UAA2B;CACjD,IAAI,KAAK,YAAA;CACT,IAAI,CAAC,GAAG,SAAS,UAAU,GAAG,KAAK,qBAAqB;CACxD,OAAO;AACT;;;;;AAMA,eAAsB,uBACpB,KACA,SACqB;CACrB,MAAM,EAAE,WAAW,aAAa,GAAG,aAAa,QAAU,WAAW,CAAC;CAEtE,MAAM,UAAkC;EACtC,cAAc,eAAe,SAAS;EACtC,KAAK;EACL,6BAA6B;EAC7B,QAAQ;EACR,SAAS;CACX;CAEA,KAAK,IAAI,UAAU,GAAG,UAAU,YAAY,WAAW;EACrD,MAAM,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,CAAC;EAExC,IAAI,IAAI,IACN,OAAO,IAAI,WAAW,MAAM,IAAI,YAAY,CAAC;EAG/C,IAAI,IAAI,WAAW,KACjB,MAAM,IAAI,MACR,qIAEF;EAIF,IAAI,UAAU,aAAa,GACzB,MAAM,IAAI,SAAS,MAAM,WAAW,GAAG,UAAU,CAAC;CAEtD;CAEA,MAAM,IAAI,MAAM,kCAAkC,WAAW,UAAU;AACzE;;;;;;;;;;;;;;;;;;;;;;AC9BA,eAAsB,aACpB,SACA,aACiB;CAEjB,MAAM,OACJ,OAAO,gBAAgB,WACnB,EAAE,WAAW,YAAY,IACzB,eAAe,CAAC;CAEtB,gBAAgB,OAAO;CAKvB,OAAO,cACL,MAHsB,wBADX,QAAQ,qBAAqB,QAAQ,UAAA,CAAY,KACf,GAAK,IAAI,GAItD,QAAQ,UACR,QAAQ,MACR,QAAQ,MACR,QAAQ,QACV;AACF;AAEA,SAAS,gBAAgB,SAAmC;CAC1D,MAAM,UAAoB,CAAC;CAE3B,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,MAAM,QAAQ,KAAK,MAAM;CACtC,IAAI,CAAC,QAAQ,MAAM,QAAQ,KAAK,MAAM;CAEtC,IAAI,QAAQ,WAAW,GAAG;CAG1B,IAAI,QAAQ,WAAW,KAAK,QAAQ,OAAO,QAAQ;EACjD,IAAI,CAAC,eAAe,SAAS,QAAQ,IAAuC,GAC1E,QAAQ,KACN,gFACF;EAEF;CACF;CAEA,MAAM,IAAI,yBACR,qCAAqC,QAAQ,KAAK,IAAI,GACxD;AACF;;;;;;;;;ACjEA,MAAM,8BAAc,IAAI,IAAI;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;AAKD,SAAgB,cACd,SACY;CACZ,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,OAAO,OAAO,KAAK,OAAO,GACnC,IAAI,YAAY,IAAI,GAAG,GACrB,OAAO,OAAO,QAAQ;CAG1B,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["hkdfSync","createHmac","timingSafeEqual","createDecipheriv"],"sources":["../src/constants.ts","../src/crypto.ts","../src/download.ts","../src/decrypt.ts","../src/bleach.ts"],"sourcesContent":["/** Mapping from message type to WhatsApp's internal media-type key name. */\nexport const mediaTypes: Record<string, string> = {\n IMAGE: 'Image',\n VIDEO: 'Video',\n AUDIO: 'Audio',\n PTT: 'Audio',\n DOCUMENT: 'Document',\n STICKER: 'Image',\n} as const;\n\n/** Message types where a missing `size` field is tolerable. */\nexport const NON_SIZE_TYPES = ['sticker'] as const;\n\n/** The default user-agent used for fetching encrypted media blobs. */\nexport const DEFAULT_USER_AGENT =\n 'WhatsApp/2.16.352 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36';\n\n/** Thrown when a message is missing fields required for decryption. */\nexport class MissingCriticalDataError extends Error {\n override readonly name = 'MissingCriticalDataError';\n\n constructor(message: string) {\n super(message);\n }\n}\n","/**\n * Core crypto routines for WhatsApp media decryption.\n *\n * Uses only `node:crypto` builtins — no external HKDF library needed.\n * `crypto.hkdfSync` landed in Node 15 and is available in Bun.\n */\nimport { createDecipheriv, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';\n\nimport { mediaTypes } from './constants';\n\n/**\n * Derive the AES-256-CBC key and IV from the media key using HKDF-SHA256,\n * then decrypt the ciphertext.\n */\nexport function decryptBuffer(\n encryptedData: Uint8Array,\n mediaKeyBase64: string,\n mediaType: string,\n expectedSize?: number,\n mimetype?: string,\n): Buffer {\n if (encryptedData.length < 26 || (encryptedData.length - 10) % 16 !== 0) {\n throw new Error('Encrypted media has an invalid ciphertext or authentication tag length.');\n }\n const ciphertext = encryptedData.subarray(0, encryptedData.length - 10);\n const tag = encryptedData.subarray(encryptedData.length - 10);\n const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');\n\n // Resolve the info string: \"WhatsApp <Type> Keys\"\n const typeKey =\n mediaTypes[mediaType.toUpperCase()] ??\n mediaTypes[\n Object.keys(mediaTypes).find((t) =>\n mimetype?.includes(t.toLowerCase()),\n ) ?? ''\n ];\n\n const info = `WhatsApp ${typeKey} Keys`;\n\n // HKDF expand to 112 bytes (iv=16 + cipherKey=32 + macKey=32 + refKey=32)\n const expanded = Buffer.from(\n hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),\n );\n\n const iv = expanded.subarray(0, 16);\n const cipherKey = expanded.subarray(16, 48);\n const macKey = expanded.subarray(48, 80);\n const expectedTag = createHmac('sha256', macKey).update(iv).update(ciphertext).digest().subarray(0, 10);\n if (!timingSafeEqual(expectedTag, tag)) {\n throw new Error('Encrypted media authentication failed.');\n }\n\n const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);\n const decrypted = Buffer.concat([\n decipher.update(ciphertext),\n decipher.final(),\n ]);\n\n if (expectedSize !== undefined && decrypted.length !== expectedSize) {\n throw new Error('Decrypted media size does not match the message.');\n }\n return decrypted;\n}\n","/**\n * Download encrypted media from WhatsApp CDN using native fetch.\n *\n * Replaces the legacy axios-based downloader.\n */\nimport {\n DEFAULT_USER_AGENT,\n} from './constants';\n\nfunction buildUserAgent(override?: string): string {\n let ua = override ?? DEFAULT_USER_AGENT;\n if (!ua.includes('WhatsApp')) ua = `WhatsApp/2.16.352 ${ua}`;\n return ua;\n}\n\n/**\n * Download an encrypted media blob from the given URL.\n * Uses the global `fetch` API (Bun native / Node 18+).\n */\nexport async function downloadEncryptedMedia(\n url: string,\n options?: { useragent?: string; maxRetries?: number; retryDelay?: number },\n): Promise<Uint8Array> {\n const { useragent, maxRetries = 3, retryDelay = 2_000 } = options ?? {};\n\n const headers: Record<string, string> = {\n 'User-Agent': buildUserAgent(useragent),\n DNT: '1',\n 'Upgrade-Insecure-Requests': '1',\n Origin: 'https://web.whatsapp.com/',\n Referer: 'https://web.whatsapp.com/',\n };\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n const res = await fetch(url, { headers });\n\n if (res.ok) {\n return new Uint8Array(await res.arrayBuffer());\n }\n\n if (res.status === 404) {\n throw new Error(\n 'This media does not exist or is no longer available on the server. ' +\n 'See: https://openwa.dev/pages/How%20to/decrypt-media.html#40439d',\n );\n }\n\n // Retry on transient errors\n if (attempt < maxRetries - 1) {\n await new Promise((r) => setTimeout(r, retryDelay));\n }\n }\n\n throw new Error(`Failed to download media after ${maxRetries} attempts`);\n}\n","/**\n * Main decryptMedia entry point.\n *\n * Downloads the encrypted blob → decrypts with AES-256-CBC via HKDF-derived keys.\n */\nimport { MissingCriticalDataError, NON_SIZE_TYPES } from './constants';\nimport { decryptBuffer } from './crypto';\nimport { downloadEncryptedMedia } from './download';\nimport type { DecryptableMessage, DecryptOptions } from './types';\n\n/**\n * Download and decrypt a WhatsApp media message.\n *\n * @param message — A message object with at least the required decryption fields.\n * @param optionsOrUa — Either a {@link DecryptOptions} object or a legacy user-agent string.\n * @returns The decrypted media as a Buffer.\n *\n * @example\n * ```ts\n * import { decryptMedia } from '@open-wa/decrypt';\n *\n * const buffer = await decryptMedia(message);\n * ```\n */\nexport async function decryptMedia(\n message: DecryptableMessage,\n optionsOrUa?: DecryptOptions | string,\n): Promise<Buffer> {\n // Normalise options — accept legacy string signature for backward compat\n const opts: DecryptOptions =\n typeof optionsOrUa === 'string'\n ? { useragent: optionsOrUa }\n : optionsOrUa ?? {};\n\n validateMessage(message);\n\n const url = (message.deprecatedMms3Url ?? message.clientUrl)!.trim();\n const encrypted = await downloadEncryptedMedia(url, opts);\n\n return decryptBuffer(\n encrypted,\n message.mediaKey,\n message.type,\n message.size,\n message.mimetype,\n );\n}\n\nfunction validateMessage(message: DecryptableMessage): void {\n const missing: string[] = [];\n\n if (!message.mediaKey) missing.push('mediaKey');\n if (!message.filehash) missing.push('filehash');\n if (!message.mimetype) missing.push('mimetype');\n if (!message.type) missing.push('type');\n if (!message.size) missing.push('size');\n\n if (missing.length === 0) return;\n\n // Size-only omission is a warning for non-sticker types, not an error\n if (missing.length === 1 && missing[0] === 'size') {\n if (!NON_SIZE_TYPES.includes(message.type as (typeof NON_SIZE_TYPES)[number])) {\n console.warn(\n '@open-wa/decrypt: size property is missing. File will fail an integrity check.',\n );\n }\n return;\n }\n\n throw new MissingCriticalDataError(\n `Message is missing critical data: ${missing.join(', ')}`,\n );\n}\n","/**\n * Strip a message down to the minimum fields needed for decryption.\n *\n * Useful for minimizing sensitive data in transit — removes sender info,\n * chat context, etc.\n */\n\nconst BLEACH_KEEP = new Set([\n 'type',\n 'clientUrl',\n 'mimetype',\n 'mediaKey',\n 'size',\n 'filehash',\n 'uploadhash',\n 'deprecatedMms3Url',\n]);\n\n/**\n * Return a copy of the message containing only the fields required for decryption.\n */\nexport function bleachMessage<T extends Record<string, unknown>>(\n message: T,\n): Partial<T> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(message)) {\n if (BLEACH_KEEP.has(key)) {\n result[key] = message[key];\n }\n }\n return result as Partial<T>;\n}\n"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createDecipheriv, hkdfSync } from "node:crypto";
|
|
1
|
+
import { createDecipheriv, createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
|
|
2
2
|
//#region src/constants.ts
|
|
3
3
|
/** Mapping from message type to WhatsApp's internal media-type key name. */
|
|
4
4
|
const mediaTypes = {
|
|
@@ -31,29 +31,21 @@ var MissingCriticalDataError = class extends Error {
|
|
|
31
31
|
* then decrypt the ciphertext.
|
|
32
32
|
*/
|
|
33
33
|
function decryptBuffer(encryptedData, mediaKeyBase64, mediaType, expectedSize, mimetype) {
|
|
34
|
+
if (encryptedData.length < 26 || (encryptedData.length - 10) % 16 !== 0) throw new Error("Encrypted media has an invalid ciphertext or authentication tag length.");
|
|
35
|
+
const ciphertext = encryptedData.subarray(0, encryptedData.length - 10);
|
|
36
|
+
const tag = encryptedData.subarray(encryptedData.length - 10);
|
|
34
37
|
const mediaKeyBytes = Buffer.from(mediaKeyBase64, "base64");
|
|
35
38
|
const info = `WhatsApp ${mediaTypes[mediaType.toUpperCase()] ?? mediaTypes[Object.keys(mediaTypes).find((t) => mimetype?.includes(t.toLowerCase())) ?? ""]} Keys`;
|
|
36
39
|
const expanded = Buffer.from(hkdfSync("sha256", mediaKeyBytes, Buffer.alloc(32), info, 112));
|
|
37
40
|
const iv = expanded.subarray(0, 16);
|
|
38
41
|
const cipherKey = expanded.subarray(16, 48);
|
|
42
|
+
const macKey = expanded.subarray(48, 80);
|
|
43
|
+
const expectedTag = createHmac("sha256", macKey).update(iv).update(ciphertext).digest().subarray(0, 10);
|
|
44
|
+
if (!timingSafeEqual(expectedTag, tag)) throw new Error("Encrypted media authentication failed.");
|
|
39
45
|
const decipher = createDecipheriv("aes-256-cbc", cipherKey, iv);
|
|
40
|
-
decipher.
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Correct PKCS#7-style padding that WhatsApp applies inconsistently.
|
|
46
|
-
*/
|
|
47
|
-
function fixPadding(data, expectedSize) {
|
|
48
|
-
const padding = 16 - expectedSize % 16 & 15;
|
|
49
|
-
if (padding > 0) {
|
|
50
|
-
if (expectedSize + padding === data.length) return data.subarray(0, data.length - padding);
|
|
51
|
-
if (data.length + padding === expectedSize) {
|
|
52
|
-
const pad = Buffer.alloc(padding, padding);
|
|
53
|
-
return Buffer.concat([data, pad]);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return data;
|
|
46
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
47
|
+
if (expectedSize !== void 0 && decrypted.length !== expectedSize) throw new Error("Decrypted media size does not match the message.");
|
|
48
|
+
return decrypted;
|
|
57
49
|
}
|
|
58
50
|
//#endregion
|
|
59
51
|
//#region src/download.ts
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/constants.ts","../src/crypto.ts","../src/download.ts","../src/decrypt.ts","../src/bleach.ts"],"sourcesContent":["/** Mapping from message type to WhatsApp's internal media-type key name. */\nexport const mediaTypes: Record<string, string> = {\n IMAGE: 'Image',\n VIDEO: 'Video',\n AUDIO: 'Audio',\n PTT: 'Audio',\n DOCUMENT: 'Document',\n STICKER: 'Image',\n} as const;\n\n/** Message types where a missing `size` field is tolerable. */\nexport const NON_SIZE_TYPES = ['sticker'] as const;\n\n/** The default user-agent used for fetching encrypted media blobs. */\nexport const DEFAULT_USER_AGENT =\n 'WhatsApp/2.16.352 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36';\n\n/** Thrown when a message is missing fields required for decryption. */\nexport class MissingCriticalDataError extends Error {\n override readonly name = 'MissingCriticalDataError';\n\n constructor(message: string) {\n super(message);\n }\n}\n","/**\n * Core crypto routines for WhatsApp media decryption.\n *\n * Uses only `node:crypto` builtins — no external HKDF library needed.\n * `crypto.hkdfSync` landed in Node 15 and is available in Bun.\n */\nimport { createDecipheriv, hkdfSync } from 'node:crypto';\n\nimport { mediaTypes } from './constants';\n\n/**\n * Derive the AES-256-CBC key and IV from the media key using HKDF-SHA256,\n * then decrypt the ciphertext.\n */\nexport function decryptBuffer(\n encryptedData: Uint8Array,\n mediaKeyBase64: string,\n mediaType: string,\n expectedSize?: number,\n mimetype?: string,\n): Buffer {\n const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');\n\n // Resolve the info string: \"WhatsApp <Type> Keys\"\n const typeKey =\n mediaTypes[mediaType.toUpperCase()] ??\n mediaTypes[\n Object.keys(mediaTypes).find((t) =>\n mimetype?.includes(t.toLowerCase()),\n ) ?? ''\n ];\n\n const info = `WhatsApp ${typeKey} Keys`;\n\n // HKDF expand to 112 bytes (iv=16 + cipherKey=32 + macKey=32 + refKey=32)\n const expanded = Buffer.from(\n hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),\n );\n\n const iv = expanded.subarray(0, 16);\n const cipherKey = expanded.subarray(16, 48);\n\n const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);\n // WhatsApp handles padding manually — disable OpenSSL auto-padding\n decipher.setAutoPadding(false);\n const decrypted = Buffer.concat([\n decipher.update(encryptedData),\n decipher.final(),\n ]);\n\n return expectedSize ? fixPadding(decrypted, expectedSize) : decrypted;\n}\n\n/**\n * Correct PKCS#7-style padding that WhatsApp applies inconsistently.\n */\nfunction fixPadding(data: Buffer, expectedSize: number): Buffer {\n const padding = (16 - (expectedSize % 16)) & 0xf;\n if (padding > 0) {\n if (expectedSize + padding === data.length) {\n // Trim trailing padding bytes\n return data.subarray(0, data.length - padding);\n }\n if (data.length + padding === expectedSize) {\n // Append missing padding\n const pad = Buffer.alloc(padding, padding);\n return Buffer.concat([data, pad]);\n }\n }\n return data;\n}\n","/**\n * Download encrypted media from WhatsApp CDN using native fetch.\n *\n * Replaces the legacy axios-based downloader.\n */\nimport {\n DEFAULT_USER_AGENT,\n} from './constants';\n\nfunction buildUserAgent(override?: string): string {\n let ua = override ?? DEFAULT_USER_AGENT;\n if (!ua.includes('WhatsApp')) ua = `WhatsApp/2.16.352 ${ua}`;\n return ua;\n}\n\n/**\n * Download an encrypted media blob from the given URL.\n * Uses the global `fetch` API (Bun native / Node 18+).\n */\nexport async function downloadEncryptedMedia(\n url: string,\n options?: { useragent?: string; maxRetries?: number; retryDelay?: number },\n): Promise<Uint8Array> {\n const { useragent, maxRetries = 3, retryDelay = 2_000 } = options ?? {};\n\n const headers: Record<string, string> = {\n 'User-Agent': buildUserAgent(useragent),\n DNT: '1',\n 'Upgrade-Insecure-Requests': '1',\n Origin: 'https://web.whatsapp.com/',\n Referer: 'https://web.whatsapp.com/',\n };\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n const res = await fetch(url, { headers });\n\n if (res.ok) {\n return new Uint8Array(await res.arrayBuffer());\n }\n\n if (res.status === 404) {\n throw new Error(\n 'This media does not exist or is no longer available on the server. ' +\n 'See: https://openwa.dev/pages/How%20to/decrypt-media.html#40439d',\n );\n }\n\n // Retry on transient errors\n if (attempt < maxRetries - 1) {\n await new Promise((r) => setTimeout(r, retryDelay));\n }\n }\n\n throw new Error(`Failed to download media after ${maxRetries} attempts`);\n}\n","/**\n * Main decryptMedia entry point.\n *\n * Downloads the encrypted blob → decrypts with AES-256-CBC via HKDF-derived keys.\n */\nimport { MissingCriticalDataError, NON_SIZE_TYPES } from './constants';\nimport { decryptBuffer } from './crypto';\nimport { downloadEncryptedMedia } from './download';\nimport type { DecryptableMessage, DecryptOptions } from './types';\n\n/**\n * Download and decrypt a WhatsApp media message.\n *\n * @param message — A message object with at least the required decryption fields.\n * @param optionsOrUa — Either a {@link DecryptOptions} object or a legacy user-agent string.\n * @returns The decrypted media as a Buffer.\n *\n * @example\n * ```ts\n * import { decryptMedia } from '@open-wa/decrypt';\n *\n * const buffer = await decryptMedia(message);\n * ```\n */\nexport async function decryptMedia(\n message: DecryptableMessage,\n optionsOrUa?: DecryptOptions | string,\n): Promise<Buffer> {\n // Normalise options — accept legacy string signature for backward compat\n const opts: DecryptOptions =\n typeof optionsOrUa === 'string'\n ? { useragent: optionsOrUa }\n : optionsOrUa ?? {};\n\n validateMessage(message);\n\n const url = (message.deprecatedMms3Url ?? message.clientUrl)!.trim();\n const encrypted = await downloadEncryptedMedia(url, opts);\n\n return decryptBuffer(\n encrypted,\n message.mediaKey,\n message.type,\n message.size,\n message.mimetype,\n );\n}\n\nfunction validateMessage(message: DecryptableMessage): void {\n const missing: string[] = [];\n\n if (!message.mediaKey) missing.push('mediaKey');\n if (!message.filehash) missing.push('filehash');\n if (!message.mimetype) missing.push('mimetype');\n if (!message.type) missing.push('type');\n if (!message.size) missing.push('size');\n\n if (missing.length === 0) return;\n\n // Size-only omission is a warning for non-sticker types, not an error\n if (missing.length === 1 && missing[0] === 'size') {\n if (!NON_SIZE_TYPES.includes(message.type as (typeof NON_SIZE_TYPES)[number])) {\n console.warn(\n '@open-wa/decrypt: size property is missing. File will fail an integrity check.',\n );\n }\n return;\n }\n\n throw new MissingCriticalDataError(\n `Message is missing critical data: ${missing.join(', ')}`,\n );\n}\n","/**\n * Strip a message down to the minimum fields needed for decryption.\n *\n * Useful for minimizing sensitive data in transit — removes sender info,\n * chat context, etc.\n */\n\nconst BLEACH_KEEP = new Set([\n 'type',\n 'clientUrl',\n 'mimetype',\n 'mediaKey',\n 'size',\n 'filehash',\n 'uploadhash',\n 'deprecatedMms3Url',\n]);\n\n/**\n * Return a copy of the message containing only the fields required for decryption.\n */\nexport function bleachMessage<T extends Record<string, unknown>>(\n message: T,\n): Partial<T> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(message)) {\n if (BLEACH_KEEP.has(key)) {\n result[key] = message[key];\n }\n }\n return result as Partial<T>;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/constants.ts","../src/crypto.ts","../src/download.ts","../src/decrypt.ts","../src/bleach.ts"],"sourcesContent":["/** Mapping from message type to WhatsApp's internal media-type key name. */\nexport const mediaTypes: Record<string, string> = {\n IMAGE: 'Image',\n VIDEO: 'Video',\n AUDIO: 'Audio',\n PTT: 'Audio',\n DOCUMENT: 'Document',\n STICKER: 'Image',\n} as const;\n\n/** Message types where a missing `size` field is tolerable. */\nexport const NON_SIZE_TYPES = ['sticker'] as const;\n\n/** The default user-agent used for fetching encrypted media blobs. */\nexport const DEFAULT_USER_AGENT =\n 'WhatsApp/2.16.352 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36';\n\n/** Thrown when a message is missing fields required for decryption. */\nexport class MissingCriticalDataError extends Error {\n override readonly name = 'MissingCriticalDataError';\n\n constructor(message: string) {\n super(message);\n }\n}\n","/**\n * Core crypto routines for WhatsApp media decryption.\n *\n * Uses only `node:crypto` builtins — no external HKDF library needed.\n * `crypto.hkdfSync` landed in Node 15 and is available in Bun.\n */\nimport { createDecipheriv, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';\n\nimport { mediaTypes } from './constants';\n\n/**\n * Derive the AES-256-CBC key and IV from the media key using HKDF-SHA256,\n * then decrypt the ciphertext.\n */\nexport function decryptBuffer(\n encryptedData: Uint8Array,\n mediaKeyBase64: string,\n mediaType: string,\n expectedSize?: number,\n mimetype?: string,\n): Buffer {\n if (encryptedData.length < 26 || (encryptedData.length - 10) % 16 !== 0) {\n throw new Error('Encrypted media has an invalid ciphertext or authentication tag length.');\n }\n const ciphertext = encryptedData.subarray(0, encryptedData.length - 10);\n const tag = encryptedData.subarray(encryptedData.length - 10);\n const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');\n\n // Resolve the info string: \"WhatsApp <Type> Keys\"\n const typeKey =\n mediaTypes[mediaType.toUpperCase()] ??\n mediaTypes[\n Object.keys(mediaTypes).find((t) =>\n mimetype?.includes(t.toLowerCase()),\n ) ?? ''\n ];\n\n const info = `WhatsApp ${typeKey} Keys`;\n\n // HKDF expand to 112 bytes (iv=16 + cipherKey=32 + macKey=32 + refKey=32)\n const expanded = Buffer.from(\n hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),\n );\n\n const iv = expanded.subarray(0, 16);\n const cipherKey = expanded.subarray(16, 48);\n const macKey = expanded.subarray(48, 80);\n const expectedTag = createHmac('sha256', macKey).update(iv).update(ciphertext).digest().subarray(0, 10);\n if (!timingSafeEqual(expectedTag, tag)) {\n throw new Error('Encrypted media authentication failed.');\n }\n\n const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);\n const decrypted = Buffer.concat([\n decipher.update(ciphertext),\n decipher.final(),\n ]);\n\n if (expectedSize !== undefined && decrypted.length !== expectedSize) {\n throw new Error('Decrypted media size does not match the message.');\n }\n return decrypted;\n}\n","/**\n * Download encrypted media from WhatsApp CDN using native fetch.\n *\n * Replaces the legacy axios-based downloader.\n */\nimport {\n DEFAULT_USER_AGENT,\n} from './constants';\n\nfunction buildUserAgent(override?: string): string {\n let ua = override ?? DEFAULT_USER_AGENT;\n if (!ua.includes('WhatsApp')) ua = `WhatsApp/2.16.352 ${ua}`;\n return ua;\n}\n\n/**\n * Download an encrypted media blob from the given URL.\n * Uses the global `fetch` API (Bun native / Node 18+).\n */\nexport async function downloadEncryptedMedia(\n url: string,\n options?: { useragent?: string; maxRetries?: number; retryDelay?: number },\n): Promise<Uint8Array> {\n const { useragent, maxRetries = 3, retryDelay = 2_000 } = options ?? {};\n\n const headers: Record<string, string> = {\n 'User-Agent': buildUserAgent(useragent),\n DNT: '1',\n 'Upgrade-Insecure-Requests': '1',\n Origin: 'https://web.whatsapp.com/',\n Referer: 'https://web.whatsapp.com/',\n };\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n const res = await fetch(url, { headers });\n\n if (res.ok) {\n return new Uint8Array(await res.arrayBuffer());\n }\n\n if (res.status === 404) {\n throw new Error(\n 'This media does not exist or is no longer available on the server. ' +\n 'See: https://openwa.dev/pages/How%20to/decrypt-media.html#40439d',\n );\n }\n\n // Retry on transient errors\n if (attempt < maxRetries - 1) {\n await new Promise((r) => setTimeout(r, retryDelay));\n }\n }\n\n throw new Error(`Failed to download media after ${maxRetries} attempts`);\n}\n","/**\n * Main decryptMedia entry point.\n *\n * Downloads the encrypted blob → decrypts with AES-256-CBC via HKDF-derived keys.\n */\nimport { MissingCriticalDataError, NON_SIZE_TYPES } from './constants';\nimport { decryptBuffer } from './crypto';\nimport { downloadEncryptedMedia } from './download';\nimport type { DecryptableMessage, DecryptOptions } from './types';\n\n/**\n * Download and decrypt a WhatsApp media message.\n *\n * @param message — A message object with at least the required decryption fields.\n * @param optionsOrUa — Either a {@link DecryptOptions} object or a legacy user-agent string.\n * @returns The decrypted media as a Buffer.\n *\n * @example\n * ```ts\n * import { decryptMedia } from '@open-wa/decrypt';\n *\n * const buffer = await decryptMedia(message);\n * ```\n */\nexport async function decryptMedia(\n message: DecryptableMessage,\n optionsOrUa?: DecryptOptions | string,\n): Promise<Buffer> {\n // Normalise options — accept legacy string signature for backward compat\n const opts: DecryptOptions =\n typeof optionsOrUa === 'string'\n ? { useragent: optionsOrUa }\n : optionsOrUa ?? {};\n\n validateMessage(message);\n\n const url = (message.deprecatedMms3Url ?? message.clientUrl)!.trim();\n const encrypted = await downloadEncryptedMedia(url, opts);\n\n return decryptBuffer(\n encrypted,\n message.mediaKey,\n message.type,\n message.size,\n message.mimetype,\n );\n}\n\nfunction validateMessage(message: DecryptableMessage): void {\n const missing: string[] = [];\n\n if (!message.mediaKey) missing.push('mediaKey');\n if (!message.filehash) missing.push('filehash');\n if (!message.mimetype) missing.push('mimetype');\n if (!message.type) missing.push('type');\n if (!message.size) missing.push('size');\n\n if (missing.length === 0) return;\n\n // Size-only omission is a warning for non-sticker types, not an error\n if (missing.length === 1 && missing[0] === 'size') {\n if (!NON_SIZE_TYPES.includes(message.type as (typeof NON_SIZE_TYPES)[number])) {\n console.warn(\n '@open-wa/decrypt: size property is missing. File will fail an integrity check.',\n );\n }\n return;\n }\n\n throw new MissingCriticalDataError(\n `Message is missing critical data: ${missing.join(', ')}`,\n );\n}\n","/**\n * Strip a message down to the minimum fields needed for decryption.\n *\n * Useful for minimizing sensitive data in transit — removes sender info,\n * chat context, etc.\n */\n\nconst BLEACH_KEEP = new Set([\n 'type',\n 'clientUrl',\n 'mimetype',\n 'mediaKey',\n 'size',\n 'filehash',\n 'uploadhash',\n 'deprecatedMms3Url',\n]);\n\n/**\n * Return a copy of the message containing only the fields required for decryption.\n */\nexport function bleachMessage<T extends Record<string, unknown>>(\n message: T,\n): Partial<T> {\n const result: Record<string, unknown> = {};\n for (const key of Object.keys(message)) {\n if (BLEACH_KEEP.has(key)) {\n result[key] = message[key];\n }\n }\n return result as Partial<T>;\n}\n"],"mappings":";;;AACA,MAAa,aAAqC;CAChD,OAAO;CACP,OAAO;CACP,OAAO;CACP,KAAK;CACL,UAAU;CACV,SAAS;AACX;;AAGA,MAAa,iBAAiB,CAAC,SAAS;;AAOxC,IAAa,2BAAb,cAA8C,MAAM;CAClD,OAAyB;CAEzB,YAAY,SAAiB;EAC3B,MAAM,OAAO;CACf;AACF;;;;;;;;;;;;;ACVA,SAAgB,cACd,eACA,gBACA,WACA,cACA,UACQ;CACR,IAAI,cAAc,SAAS,OAAO,cAAc,SAAS,MAAM,OAAO,GACpE,MAAM,IAAI,MAAM,yEAAyE;CAE3F,MAAM,aAAa,cAAc,SAAS,GAAG,cAAc,SAAS,EAAE;CACtE,MAAM,MAAM,cAAc,SAAS,cAAc,SAAS,EAAE;CAC5D,MAAM,gBAAgB,OAAO,KAAK,gBAAgB,QAAQ;CAW1D,MAAM,OAAO,YAPX,WAAW,UAAU,YAAY,MACjC,WACE,OAAO,KAAK,UAAU,CAAC,CAAC,MAAM,MAC5B,UAAU,SAAS,EAAE,YAAY,CAAC,CACpC,KAAK,IAGwB;CAGjC,MAAM,WAAW,OAAO,KACtB,SAAS,UAAU,eAAe,OAAO,MAAM,EAAE,GAAG,MAAM,GAAG,CAC/D;CAEA,MAAM,KAAK,SAAS,SAAS,GAAG,EAAE;CAClC,MAAM,YAAY,SAAS,SAAS,IAAI,EAAE;CAC1C,MAAM,SAAS,SAAS,SAAS,IAAI,EAAE;CACvC,MAAM,cAAc,WAAW,UAAU,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,OAAO,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,SAAS,GAAG,EAAE;CACtG,IAAI,CAAC,gBAAgB,aAAa,GAAG,GACnC,MAAM,IAAI,MAAM,wCAAwC;CAG1D,MAAM,WAAW,iBAAiB,eAAe,WAAW,EAAE;CAC9D,MAAM,YAAY,OAAO,OAAO,CAC9B,SAAS,OAAO,UAAU,GAC1B,SAAS,MAAM,CACjB,CAAC;CAED,IAAI,iBAAiB,KAAA,KAAa,UAAU,WAAW,cACrD,MAAM,IAAI,MAAM,kDAAkD;CAEpE,OAAO;AACT;;;;;;;;ACrDA,SAAS,eAAe,UAA2B;CACjD,IAAI,KAAK,YAAA;CACT,IAAI,CAAC,GAAG,SAAS,UAAU,GAAG,KAAK,qBAAqB;CACxD,OAAO;AACT;;;;;AAMA,eAAsB,uBACpB,KACA,SACqB;CACrB,MAAM,EAAE,WAAW,aAAa,GAAG,aAAa,QAAU,WAAW,CAAC;CAEtE,MAAM,UAAkC;EACtC,cAAc,eAAe,SAAS;EACtC,KAAK;EACL,6BAA6B;EAC7B,QAAQ;EACR,SAAS;CACX;CAEA,KAAK,IAAI,UAAU,GAAG,UAAU,YAAY,WAAW;EACrD,MAAM,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,CAAC;EAExC,IAAI,IAAI,IACN,OAAO,IAAI,WAAW,MAAM,IAAI,YAAY,CAAC;EAG/C,IAAI,IAAI,WAAW,KACjB,MAAM,IAAI,MACR,qIAEF;EAIF,IAAI,UAAU,aAAa,GACzB,MAAM,IAAI,SAAS,MAAM,WAAW,GAAG,UAAU,CAAC;CAEtD;CAEA,MAAM,IAAI,MAAM,kCAAkC,WAAW,UAAU;AACzE;;;;;;;;;;;;;;;;;;;;;;AC9BA,eAAsB,aACpB,SACA,aACiB;CAEjB,MAAM,OACJ,OAAO,gBAAgB,WACnB,EAAE,WAAW,YAAY,IACzB,eAAe,CAAC;CAEtB,gBAAgB,OAAO;CAKvB,OAAO,cACL,MAHsB,wBADX,QAAQ,qBAAqB,QAAQ,UAAA,CAAY,KACf,GAAK,IAAI,GAItD,QAAQ,UACR,QAAQ,MACR,QAAQ,MACR,QAAQ,QACV;AACF;AAEA,SAAS,gBAAgB,SAAmC;CAC1D,MAAM,UAAoB,CAAC;CAE3B,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,UAAU,QAAQ,KAAK,UAAU;CAC9C,IAAI,CAAC,QAAQ,MAAM,QAAQ,KAAK,MAAM;CACtC,IAAI,CAAC,QAAQ,MAAM,QAAQ,KAAK,MAAM;CAEtC,IAAI,QAAQ,WAAW,GAAG;CAG1B,IAAI,QAAQ,WAAW,KAAK,QAAQ,OAAO,QAAQ;EACjD,IAAI,CAAC,eAAe,SAAS,QAAQ,IAAuC,GAC1E,QAAQ,KACN,gFACF;EAEF;CACF;CAEA,MAAM,IAAI,yBACR,qCAAqC,QAAQ,KAAK,IAAI,GACxD;AACF;;;;;;;;;ACjEA,MAAM,8BAAc,IAAI,IAAI;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;AAKD,SAAgB,cACd,SACY;CACZ,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,OAAO,OAAO,KAAK,OAAO,GACnC,IAAI,YAAY,IAAI,GAAG,GACrB,OAAO,OAAO,QAAQ;CAG1B,OAAO;AACT"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-wa/decrypt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.7",
|
|
4
4
|
"description": "Lightweight WhatsApp media decryption — zero external dependencies",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@open-wa/schema": "5.
|
|
22
|
+
"@open-wa/schema": "5.9.7",
|
|
23
23
|
"@types/node": "^26.6.3",
|
|
24
24
|
"typescript": "^7.0.2",
|
|
25
25
|
"vitest": "5.0.2"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@open-wa/schema": "5.
|
|
28
|
+
"@open-wa/schema": "5.9.7"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
31
31
|
"@open-wa/schema": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { createCipheriv, randomBytes, hkdfSync } from 'node:crypto';
|
|
2
|
+
import { createCipheriv, createHmac, randomBytes, hkdfSync } from 'node:crypto';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
decryptMedia,
|
|
@@ -12,7 +12,7 @@ import { decryptBuffer } from '../crypto';
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Build a synthetic encrypted payload matching WhatsApp's scheme:
|
|
15
|
-
* HKDF → AES-256-CBC → ciphertext
|
|
15
|
+
* HKDF → AES-256-CBC → ciphertext with WhatsApp's 10-byte authentication tag.
|
|
16
16
|
*/
|
|
17
17
|
function encryptTestPayload(
|
|
18
18
|
plaintext: Buffer,
|
|
@@ -38,7 +38,9 @@ function encryptTestPayload(
|
|
|
38
38
|
Buffer.alloc(padding, padding),
|
|
39
39
|
]);
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const ciphertext = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
42
|
+
const mac = createHmac('sha256', expanded.subarray(48, 80)).update(iv).update(ciphertext).digest().subarray(0, 10);
|
|
43
|
+
return Buffer.concat([ciphertext, mac]);
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
describe('decryptBuffer', () => {
|
|
@@ -76,6 +78,21 @@ describe('decryptBuffer', () => {
|
|
|
76
78
|
expect(result.toString()).toBe(`test-${type}`);
|
|
77
79
|
}
|
|
78
80
|
});
|
|
81
|
+
it('removes a complete block of PKCS#7 padding and works without an expected size', () => {
|
|
82
|
+
const key = randomBytes(32).toString('base64');
|
|
83
|
+
const plain = Buffer.alloc(16, 7);
|
|
84
|
+
const encrypted = encryptTestPayload(plain, key, 'PTT');
|
|
85
|
+
expect(decryptBuffer(encrypted, key, 'ptt', plain.length, 'audio/ogg').equals(plain)).toBe(true);
|
|
86
|
+
expect(decryptBuffer(encrypted, key, 'ptt', undefined, 'audio/ogg').equals(plain)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('rejects corrupted ciphertext and authentication tags', () => {
|
|
89
|
+
const key = randomBytes(32).toString('base64');
|
|
90
|
+
const encrypted = encryptTestPayload(Buffer.from('voice note'), key, 'PTT');
|
|
91
|
+
const badCiphertext = Buffer.from(encrypted); badCiphertext[0] ^= 1;
|
|
92
|
+
const badTag = Buffer.from(encrypted); badTag[badTag.length - 1] ^= 1;
|
|
93
|
+
expect(() => decryptBuffer(badCiphertext, key, 'ptt')).toThrow('authentication');
|
|
94
|
+
expect(() => decryptBuffer(badTag, key, 'ptt')).toThrow('authentication');
|
|
95
|
+
});
|
|
79
96
|
});
|
|
80
97
|
|
|
81
98
|
describe('bleachMessage', () => {
|
package/src/crypto.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses only `node:crypto` builtins — no external HKDF library needed.
|
|
5
5
|
* `crypto.hkdfSync` landed in Node 15 and is available in Bun.
|
|
6
6
|
*/
|
|
7
|
-
import { createDecipheriv, hkdfSync } from 'node:crypto';
|
|
7
|
+
import { createDecipheriv, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
8
8
|
|
|
9
9
|
import { mediaTypes } from './constants';
|
|
10
10
|
|
|
@@ -19,6 +19,11 @@ export function decryptBuffer(
|
|
|
19
19
|
expectedSize?: number,
|
|
20
20
|
mimetype?: string,
|
|
21
21
|
): Buffer {
|
|
22
|
+
if (encryptedData.length < 26 || (encryptedData.length - 10) % 16 !== 0) {
|
|
23
|
+
throw new Error('Encrypted media has an invalid ciphertext or authentication tag length.');
|
|
24
|
+
}
|
|
25
|
+
const ciphertext = encryptedData.subarray(0, encryptedData.length - 10);
|
|
26
|
+
const tag = encryptedData.subarray(encryptedData.length - 10);
|
|
22
27
|
const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');
|
|
23
28
|
|
|
24
29
|
// Resolve the info string: "WhatsApp <Type> Keys"
|
|
@@ -39,33 +44,20 @@ export function decryptBuffer(
|
|
|
39
44
|
|
|
40
45
|
const iv = expanded.subarray(0, 16);
|
|
41
46
|
const cipherKey = expanded.subarray(16, 48);
|
|
47
|
+
const macKey = expanded.subarray(48, 80);
|
|
48
|
+
const expectedTag = createHmac('sha256', macKey).update(iv).update(ciphertext).digest().subarray(0, 10);
|
|
49
|
+
if (!timingSafeEqual(expectedTag, tag)) {
|
|
50
|
+
throw new Error('Encrypted media authentication failed.');
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);
|
|
44
|
-
// WhatsApp handles padding manually — disable OpenSSL auto-padding
|
|
45
|
-
decipher.setAutoPadding(false);
|
|
46
54
|
const decrypted = Buffer.concat([
|
|
47
|
-
decipher.update(
|
|
55
|
+
decipher.update(ciphertext),
|
|
48
56
|
decipher.final(),
|
|
49
57
|
]);
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Correct PKCS#7-style padding that WhatsApp applies inconsistently.
|
|
56
|
-
*/
|
|
57
|
-
function fixPadding(data: Buffer, expectedSize: number): Buffer {
|
|
58
|
-
const padding = (16 - (expectedSize % 16)) & 0xf;
|
|
59
|
-
if (padding > 0) {
|
|
60
|
-
if (expectedSize + padding === data.length) {
|
|
61
|
-
// Trim trailing padding bytes
|
|
62
|
-
return data.subarray(0, data.length - padding);
|
|
63
|
-
}
|
|
64
|
-
if (data.length + padding === expectedSize) {
|
|
65
|
-
// Append missing padding
|
|
66
|
-
const pad = Buffer.alloc(padding, padding);
|
|
67
|
-
return Buffer.concat([data, pad]);
|
|
68
|
-
}
|
|
59
|
+
if (expectedSize !== undefined && decrypted.length !== expectedSize) {
|
|
60
|
+
throw new Error('Decrypted media size does not match the message.');
|
|
69
61
|
}
|
|
70
|
-
return
|
|
62
|
+
return decrypted;
|
|
71
63
|
}
|