@open-wa/decrypt 5.0.0 → 5.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-wa/decrypt",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Lightweight WhatsApp media decryption — zero external dependencies",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -14,17 +14,18 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
+ "src",
17
18
  "dist"
18
19
  ],
19
20
  "dependencies": {},
20
21
  "devDependencies": {
21
- "@open-wa/schema": "5.0.0",
22
+ "@open-wa/schema": "5.1.0",
22
23
  "@types/node": "^26.6.3",
23
24
  "typescript": "^7.0.2",
24
25
  "vitest": "5.0.2"
25
26
  },
26
27
  "peerDependencies": {
27
- "@open-wa/schema": "5.0.0"
28
+ "@open-wa/schema": "5.1.0"
28
29
  },
29
30
  "peerDependenciesMeta": {
30
31
  "@open-wa/schema": {
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createCipheriv, randomBytes, hkdfSync } from 'node:crypto';
3
+
4
+ import {
5
+ decryptMedia,
6
+ bleachMessage,
7
+ mediaTypes,
8
+ MissingCriticalDataError,
9
+ } from '../index';
10
+ import type { DecryptableMessage } from '../types';
11
+ import { decryptBuffer } from '../crypto';
12
+
13
+ /**
14
+ * Build a synthetic encrypted payload matching WhatsApp's scheme:
15
+ * HKDF → AES-256-CBC → ciphertext that decryptBuffer can reverse.
16
+ */
17
+ function encryptTestPayload(
18
+ plaintext: Buffer,
19
+ mediaKeyBase64: string,
20
+ type: string,
21
+ ): Buffer {
22
+ const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');
23
+ const info = `WhatsApp ${mediaTypes[type.toUpperCase()]} Keys`;
24
+
25
+ const expanded = Buffer.from(
26
+ hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),
27
+ );
28
+ const iv = expanded.subarray(0, 16);
29
+ const cipherKey = expanded.subarray(16, 48);
30
+
31
+ const cipher = createCipheriv('aes-256-cbc', cipherKey, iv);
32
+ cipher.setAutoPadding(false);
33
+
34
+ // Manually apply PKCS#7 padding
35
+ const padding = 16 - (plaintext.length % 16);
36
+ const padded = Buffer.concat([
37
+ plaintext,
38
+ Buffer.alloc(padding, padding),
39
+ ]);
40
+
41
+ return Buffer.concat([cipher.update(padded), cipher.final()]);
42
+ }
43
+
44
+ describe('decryptBuffer', () => {
45
+ it('decrypts a synthetic payload correctly', () => {
46
+ const mediaKey = randomBytes(32).toString('base64');
47
+ const plaintext = Buffer.from('hello open-wa decrypt');
48
+
49
+ const encrypted = encryptTestPayload(plaintext, mediaKey, 'IMAGE');
50
+
51
+ const result = decryptBuffer(
52
+ encrypted,
53
+ mediaKey,
54
+ 'IMAGE',
55
+ plaintext.length,
56
+ 'image/jpeg',
57
+ );
58
+
59
+ expect(result.toString()).toBe('hello open-wa decrypt');
60
+ });
61
+
62
+ it('handles different media types', () => {
63
+ for (const type of ['VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER']) {
64
+ const mediaKey = randomBytes(32).toString('base64');
65
+ const plaintext = Buffer.from(`test-${type}`);
66
+ const encrypted = encryptTestPayload(plaintext, mediaKey, type);
67
+
68
+ const result = decryptBuffer(
69
+ encrypted,
70
+ mediaKey,
71
+ type,
72
+ plaintext.length,
73
+ 'application/octet-stream',
74
+ );
75
+
76
+ expect(result.toString()).toBe(`test-${type}`);
77
+ }
78
+ });
79
+ });
80
+
81
+ describe('bleachMessage', () => {
82
+ it('keeps only decryption-relevant fields', () => {
83
+ const msg = {
84
+ type: 'image',
85
+ mediaKey: 'abc',
86
+ mimetype: 'image/jpeg',
87
+ size: 1000,
88
+ filehash: 'xyz',
89
+ deprecatedMms3Url: 'https://example.com',
90
+ // These should be stripped:
91
+ sender: 'user@c.us',
92
+ body: 'some text',
93
+ chat: { id: 'chat@g.us' },
94
+ };
95
+
96
+ const result = bleachMessage(msg);
97
+
98
+ expect(result).toHaveProperty('type');
99
+ expect(result).toHaveProperty('mediaKey');
100
+ expect(result).toHaveProperty('mimetype');
101
+ expect(result).toHaveProperty('size');
102
+ expect(result).toHaveProperty('filehash');
103
+ expect(result).toHaveProperty('deprecatedMms3Url');
104
+ expect(result).not.toHaveProperty('sender');
105
+ expect(result).not.toHaveProperty('body');
106
+ expect(result).not.toHaveProperty('chat');
107
+ });
108
+ });
109
+
110
+ describe('decryptMedia validation', () => {
111
+ it('throws MissingCriticalDataError when mediaKey is missing', async () => {
112
+ const bad = {
113
+ filehash: 'x',
114
+ mimetype: 'image/jpeg',
115
+ type: 'image',
116
+ size: 100,
117
+ deprecatedMms3Url: 'https://example.com',
118
+ } as unknown as DecryptableMessage;
119
+
120
+ await expect(decryptMedia(bad)).rejects.toThrow(MissingCriticalDataError);
121
+ });
122
+
123
+ it('throws MissingCriticalDataError when multiple fields are missing', async () => {
124
+ const bad = {
125
+ type: 'image',
126
+ deprecatedMms3Url: 'https://example.com',
127
+ } as unknown as DecryptableMessage;
128
+
129
+ await expect(decryptMedia(bad)).rejects.toThrow(MissingCriticalDataError);
130
+ });
131
+ });
132
+
133
+ describe('mediaTypes', () => {
134
+ it('maps all expected types', () => {
135
+ expect(mediaTypes['IMAGE']).toBe('Image');
136
+ expect(mediaTypes['VIDEO']).toBe('Video');
137
+ expect(mediaTypes['AUDIO']).toBe('Audio');
138
+ expect(mediaTypes['PTT']).toBe('Audio');
139
+ expect(mediaTypes['DOCUMENT']).toBe('Document');
140
+ expect(mediaTypes['STICKER']).toBe('Image');
141
+ });
142
+ });
package/src/bleach.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Strip a message down to the minimum fields needed for decryption.
3
+ *
4
+ * Useful for minimizing sensitive data in transit — removes sender info,
5
+ * chat context, etc.
6
+ */
7
+
8
+ const BLEACH_KEEP = new Set([
9
+ 'type',
10
+ 'clientUrl',
11
+ 'mimetype',
12
+ 'mediaKey',
13
+ 'size',
14
+ 'filehash',
15
+ 'uploadhash',
16
+ 'deprecatedMms3Url',
17
+ ]);
18
+
19
+ /**
20
+ * Return a copy of the message containing only the fields required for decryption.
21
+ */
22
+ export function bleachMessage<T extends Record<string, unknown>>(
23
+ message: T,
24
+ ): Partial<T> {
25
+ const result: Record<string, unknown> = {};
26
+ for (const key of Object.keys(message)) {
27
+ if (BLEACH_KEEP.has(key)) {
28
+ result[key] = message[key];
29
+ }
30
+ }
31
+ return result as Partial<T>;
32
+ }
@@ -0,0 +1,25 @@
1
+ /** Mapping from message type to WhatsApp's internal media-type key name. */
2
+ export const mediaTypes: Record<string, string> = {
3
+ IMAGE: 'Image',
4
+ VIDEO: 'Video',
5
+ AUDIO: 'Audio',
6
+ PTT: 'Audio',
7
+ DOCUMENT: 'Document',
8
+ STICKER: 'Image',
9
+ } as const;
10
+
11
+ /** Message types where a missing `size` field is tolerable. */
12
+ export const NON_SIZE_TYPES = ['sticker'] as const;
13
+
14
+ /** The default user-agent used for fetching encrypted media blobs. */
15
+ export const DEFAULT_USER_AGENT =
16
+ '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';
17
+
18
+ /** Thrown when a message is missing fields required for decryption. */
19
+ export class MissingCriticalDataError extends Error {
20
+ override readonly name = 'MissingCriticalDataError';
21
+
22
+ constructor(message: string) {
23
+ super(message);
24
+ }
25
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Core crypto routines for WhatsApp media decryption.
3
+ *
4
+ * Uses only `node:crypto` builtins — no external HKDF library needed.
5
+ * `crypto.hkdfSync` landed in Node 15 and is available in Bun.
6
+ */
7
+ import { createDecipheriv, hkdfSync } from 'node:crypto';
8
+
9
+ import { mediaTypes } from './constants';
10
+
11
+ /**
12
+ * Derive the AES-256-CBC key and IV from the media key using HKDF-SHA256,
13
+ * then decrypt the ciphertext.
14
+ */
15
+ export function decryptBuffer(
16
+ encryptedData: Uint8Array,
17
+ mediaKeyBase64: string,
18
+ mediaType: string,
19
+ expectedSize?: number,
20
+ mimetype?: string,
21
+ ): Buffer {
22
+ const mediaKeyBytes = Buffer.from(mediaKeyBase64, 'base64');
23
+
24
+ // Resolve the info string: "WhatsApp <Type> Keys"
25
+ const typeKey =
26
+ mediaTypes[mediaType.toUpperCase()] ??
27
+ mediaTypes[
28
+ Object.keys(mediaTypes).find((t) =>
29
+ mimetype?.includes(t.toLowerCase()),
30
+ ) ?? ''
31
+ ];
32
+
33
+ const info = `WhatsApp ${typeKey} Keys`;
34
+
35
+ // HKDF expand to 112 bytes (iv=16 + cipherKey=32 + macKey=32 + refKey=32)
36
+ const expanded = Buffer.from(
37
+ hkdfSync('sha256', mediaKeyBytes, Buffer.alloc(32), info, 112),
38
+ );
39
+
40
+ const iv = expanded.subarray(0, 16);
41
+ const cipherKey = expanded.subarray(16, 48);
42
+
43
+ const decipher = createDecipheriv('aes-256-cbc', cipherKey, iv);
44
+ // WhatsApp handles padding manually — disable OpenSSL auto-padding
45
+ decipher.setAutoPadding(false);
46
+ const decrypted = Buffer.concat([
47
+ decipher.update(encryptedData),
48
+ decipher.final(),
49
+ ]);
50
+
51
+ return expectedSize ? fixPadding(decrypted, expectedSize) : decrypted;
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
+ }
69
+ }
70
+ return data;
71
+ }
package/src/decrypt.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Main decryptMedia entry point.
3
+ *
4
+ * Downloads the encrypted blob → decrypts with AES-256-CBC via HKDF-derived keys.
5
+ */
6
+ import { MissingCriticalDataError, NON_SIZE_TYPES } from './constants';
7
+ import { decryptBuffer } from './crypto';
8
+ import { downloadEncryptedMedia } from './download';
9
+ import type { DecryptableMessage, DecryptOptions } from './types';
10
+
11
+ /**
12
+ * Download and decrypt a WhatsApp media message.
13
+ *
14
+ * @param message — A message object with at least the required decryption fields.
15
+ * @param optionsOrUa — Either a {@link DecryptOptions} object or a legacy user-agent string.
16
+ * @returns The decrypted media as a Buffer.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { decryptMedia } from '@open-wa/decrypt';
21
+ *
22
+ * const buffer = await decryptMedia(message);
23
+ * ```
24
+ */
25
+ export async function decryptMedia(
26
+ message: DecryptableMessage,
27
+ optionsOrUa?: DecryptOptions | string,
28
+ ): Promise<Buffer> {
29
+ // Normalise options — accept legacy string signature for backward compat
30
+ const opts: DecryptOptions =
31
+ typeof optionsOrUa === 'string'
32
+ ? { useragent: optionsOrUa }
33
+ : optionsOrUa ?? {};
34
+
35
+ validateMessage(message);
36
+
37
+ const url = (message.deprecatedMms3Url ?? message.clientUrl)!.trim();
38
+ const encrypted = await downloadEncryptedMedia(url, opts);
39
+
40
+ return decryptBuffer(
41
+ encrypted,
42
+ message.mediaKey,
43
+ message.type,
44
+ message.size,
45
+ message.mimetype,
46
+ );
47
+ }
48
+
49
+ function validateMessage(message: DecryptableMessage): void {
50
+ const missing: string[] = [];
51
+
52
+ if (!message.mediaKey) missing.push('mediaKey');
53
+ if (!message.filehash) missing.push('filehash');
54
+ if (!message.mimetype) missing.push('mimetype');
55
+ if (!message.type) missing.push('type');
56
+ if (!message.size) missing.push('size');
57
+
58
+ if (missing.length === 0) return;
59
+
60
+ // Size-only omission is a warning for non-sticker types, not an error
61
+ if (missing.length === 1 && missing[0] === 'size') {
62
+ if (!NON_SIZE_TYPES.includes(message.type as (typeof NON_SIZE_TYPES)[number])) {
63
+ console.warn(
64
+ '@open-wa/decrypt: size property is missing. File will fail an integrity check.',
65
+ );
66
+ }
67
+ return;
68
+ }
69
+
70
+ throw new MissingCriticalDataError(
71
+ `Message is missing critical data: ${missing.join(', ')}`,
72
+ );
73
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Download encrypted media from WhatsApp CDN using native fetch.
3
+ *
4
+ * Replaces the legacy axios-based downloader.
5
+ */
6
+ import {
7
+ DEFAULT_USER_AGENT,
8
+ } from './constants';
9
+
10
+ function buildUserAgent(override?: string): string {
11
+ let ua = override ?? DEFAULT_USER_AGENT;
12
+ if (!ua.includes('WhatsApp')) ua = `WhatsApp/2.16.352 ${ua}`;
13
+ return ua;
14
+ }
15
+
16
+ /**
17
+ * Download an encrypted media blob from the given URL.
18
+ * Uses the global `fetch` API (Bun native / Node 18+).
19
+ */
20
+ export async function downloadEncryptedMedia(
21
+ url: string,
22
+ options?: { useragent?: string; maxRetries?: number; retryDelay?: number },
23
+ ): Promise<Uint8Array> {
24
+ const { useragent, maxRetries = 3, retryDelay = 2_000 } = options ?? {};
25
+
26
+ const headers: Record<string, string> = {
27
+ 'User-Agent': buildUserAgent(useragent),
28
+ DNT: '1',
29
+ 'Upgrade-Insecure-Requests': '1',
30
+ Origin: 'https://web.whatsapp.com/',
31
+ Referer: 'https://web.whatsapp.com/',
32
+ };
33
+
34
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
35
+ const res = await fetch(url, { headers });
36
+
37
+ if (res.ok) {
38
+ return new Uint8Array(await res.arrayBuffer());
39
+ }
40
+
41
+ if (res.status === 404) {
42
+ throw new Error(
43
+ 'This media does not exist or is no longer available on the server. ' +
44
+ 'See: https://openwa.dev/pages/How%20to/decrypt-media.html#40439d',
45
+ );
46
+ }
47
+
48
+ // Retry on transient errors
49
+ if (attempt < maxRetries - 1) {
50
+ await new Promise((r) => setTimeout(r, retryDelay));
51
+ }
52
+ }
53
+
54
+ throw new Error(`Failed to download media after ${maxRetries} attempts`);
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @open-wa/decrypt — WhatsApp media decryption with zero external dependencies.
3
+ *
4
+ * All crypto uses node:crypto builtins (hkdfSync, createDecipheriv).
5
+ * HTTP fetching uses the global fetch API (Bun/Node 18+).
6
+ */
7
+
8
+ export { decryptMedia } from './decrypt';
9
+ export { bleachMessage } from './bleach';
10
+ export type {
11
+ DecryptableMessage,
12
+ RequiredDecryptionMessage,
13
+ DecryptOptions,
14
+ } from './types';
15
+ export { mediaTypes, MissingCriticalDataError } from './constants';
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Type definitions for decryptable WhatsApp media messages.
3
+ *
4
+ * Self-contained — no external type deps like type-fest or wa-automate-types-only.
5
+ */
6
+
7
+ /**
8
+ * Utility type: at least one of the specified keys must be present.
9
+ * Replaces the `type-fest` RequireAtLeastOne import.
10
+ */
11
+ type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
12
+ T,
13
+ Exclude<keyof T, Keys>
14
+ > &
15
+ {
16
+ [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
17
+ }[Keys];
18
+
19
+ /** The absolute minimum fields needed for decryption. */
20
+ export interface RequiredDecryptionMessage {
21
+ mediaKey: string;
22
+ filehash: string;
23
+ mimetype: string;
24
+ type: string;
25
+ size: number;
26
+ }
27
+
28
+ /**
29
+ * A message with enough data to download + decrypt the media attachment.
30
+ * At least one of `clientUrl` or `deprecatedMms3Url` must be present.
31
+ */
32
+ export type DecryptableMessage = RequireAtLeastOne<
33
+ {
34
+ clientUrl?: string;
35
+ deprecatedMms3Url?: string;
36
+ },
37
+ 'clientUrl' | 'deprecatedMms3Url'
38
+ > &
39
+ RequiredDecryptionMessage;
40
+
41
+ /** Options for the decryptMedia function. */
42
+ export interface DecryptOptions {
43
+ /** Custom User-Agent header for the download request. */
44
+ useragent?: string;
45
+ /** Maximum number of retry attempts for the download (default: 3). */
46
+ maxRetries?: number;
47
+ /** Delay in ms between retries (default: 2000). */
48
+ retryDelay?: number;
49
+ }