From fcca3a17ec96e02ee4b971d980ac8c2b540010fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Wed, 28 Jan 2026 22:45:08 +0900 Subject: [PATCH] chore: replace crypto-js --- lib/crypto/aes.js | 9 + lib/crypto/md5.js | 9 + lib/crypto/random.js | 5 + lib/crypto/rc4.js | 23 ++ lib/crypto/sha256.js | 5 + lib/mixins/attachments.js | 6 +- lib/security.js | 258 +++++++++------------ package.json | 4 +- rollup.config.js | 5 +- tests/unit/crypto.spec.js | 161 +++++++++++++ tests/unit/security.spec.js | 444 ++++++++++++++++++++++++++++++++++++ yarn.lock | 32 ++- 12 files changed, 800 insertions(+), 161 deletions(-) create mode 100644 lib/crypto/aes.js create mode 100644 lib/crypto/md5.js create mode 100644 lib/crypto/random.js create mode 100644 lib/crypto/rc4.js create mode 100644 lib/crypto/sha256.js create mode 100644 tests/unit/crypto.spec.js create mode 100644 tests/unit/security.spec.js diff --git a/lib/crypto/aes.js b/lib/crypto/aes.js new file mode 100644 index 00000000..dc6fe195 --- /dev/null +++ b/lib/crypto/aes.js @@ -0,0 +1,9 @@ +import { cbc, ecb } from '@noble/ciphers/aes'; + +export function aesCbcEncrypt(data, key, iv, padding = true) { + return cbc(key, iv, { disablePadding: !padding }).encrypt(data); +} + +export function aesEcbEncrypt(data, key) { + return ecb(key, { disablePadding: true }).encrypt(data); +} diff --git a/lib/crypto/md5.js b/lib/crypto/md5.js new file mode 100644 index 00000000..8d694ba8 --- /dev/null +++ b/lib/crypto/md5.js @@ -0,0 +1,9 @@ +import md5 from 'js-md5'; + +export function md5Hash(data) { + return new Uint8Array(md5.arrayBuffer(data)); +} + +export function md5Hex(data) { + return md5(data); +} diff --git a/lib/crypto/random.js b/lib/crypto/random.js new file mode 100644 index 00000000..7eb77783 --- /dev/null +++ b/lib/crypto/random.js @@ -0,0 +1,5 @@ +export default function randomBytes(length) { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + return bytes; +} diff --git a/lib/crypto/rc4.js b/lib/crypto/rc4.js new file mode 100644 index 00000000..bd7219b3 --- /dev/null +++ b/lib/crypto/rc4.js @@ -0,0 +1,23 @@ +// RC4 (for legacy PDF 1.3-1.5) +export default function rc4(data, key) { + const s = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + s[i] = i; + } + + let j = 0; + for (let i = 0; i < 256; i++) { + j = (j + s[i] + key[i % key.length]) & 0xff; + [s[i], s[j]] = [s[j], s[i]]; + } + + const output = new Uint8Array(data.length); + for (let i = 0, j = 0, k = 0; k < data.length; k++) { + i = (i + 1) & 0xff; + j = (j + s[i]) & 0xff; + [s[i], s[j]] = [s[j], s[i]]; + output[k] = data[k] ^ s[(s[i] + s[j]) & 0xff]; + } + + return output; +} diff --git a/lib/crypto/sha256.js b/lib/crypto/sha256.js new file mode 100644 index 00000000..6e4e202a --- /dev/null +++ b/lib/crypto/sha256.js @@ -0,0 +1,5 @@ +import { sha256 } from '@noble/hashes/sha256'; + +export default function sha256Hash(data) { + return sha256(data); +} diff --git a/lib/mixins/attachments.js b/lib/mixins/attachments.js index 298e89c3..063a76e6 100644 --- a/lib/mixins/attachments.js +++ b/lib/mixins/attachments.js @@ -1,5 +1,5 @@ import fs from 'fs'; -import CryptoJS from 'crypto-js'; +import { md5Hex } from '../crypto/md5'; export default { /** @@ -65,9 +65,7 @@ export default { } // add checksum and size information - const checksum = CryptoJS.MD5( - CryptoJS.lib.WordArray.create(new Uint8Array(data)), - ); + const checksum = md5Hex(new Uint8Array(data)); refBody.Params.CheckSum = new String(checksum); refBody.Params.Size = data.byteLength; diff --git a/lib/security.js b/lib/security.js index faa91eb9..9ddd9706 100644 --- a/lib/security.js +++ b/lib/security.js @@ -3,7 +3,12 @@ By Yang Liu */ -import CryptoJS from 'crypto-js'; +import { concatBytes } from '@noble/hashes/utils'; +import { md5Hash } from './crypto/md5'; +import sha256Hash from './crypto/sha256'; +import { aesCbcEncrypt, aesEcbEncrypt } from './crypto/aes'; +import rc4 from './crypto/rc4'; +import randomBytes from './crypto/random'; import saslprep from './saslprep/index'; class PDFSecurity { @@ -18,11 +23,11 @@ class PDFSecurity { infoStr += `${key}: ${info[key].valueOf()}\n`; } - return wordArrayToBuffer(CryptoJS.MD5(infoStr)); + return Buffer.from(md5Hash(infoStr)); } static generateRandomWordArray(bytes) { - return CryptoJS.lib.WordArray.random(bytes); + return randomBytes(bytes); } static create(document, options = {}) { @@ -142,8 +147,8 @@ class PDFSecurity { encDict.StrF = 'StdCF'; } encDict.R = r; - encDict.O = wordArrayToBuffer(ownerPasswordEntry); - encDict.U = wordArrayToBuffer(userPasswordEntry); + encDict.O = Buffer.from(ownerPasswordEntry); + encDict.U = Buffer.from(userPasswordEntry); encDict.P = permissions; } @@ -163,10 +168,7 @@ class PDFSecurity { processedUserPassword, PDFSecurity.generateRandomWordArray, ); - const userKeySalt = CryptoJS.lib.WordArray.create( - userPasswordEntry.words.slice(10, 12), - 8, - ); + const userKeySalt = userPasswordEntry.slice(40, 48); const userEncryptionKeyEntry = getUserEncryptionKeyR5( processedUserPassword, userKeySalt, @@ -177,10 +179,7 @@ class PDFSecurity { userPasswordEntry, PDFSecurity.generateRandomWordArray, ); - const ownerKeySalt = CryptoJS.lib.WordArray.create( - ownerPasswordEntry.words.slice(10, 12), - 8, - ); + const ownerKeySalt = ownerPasswordEntry.slice(40, 48); const ownerEncryptionKeyEntry = getOwnerEncryptionKeyR5( processedOwnerPassword, ownerKeySalt, @@ -205,71 +204,50 @@ class PDFSecurity { encDict.StmF = 'StdCF'; encDict.StrF = 'StdCF'; encDict.R = 5; - encDict.O = wordArrayToBuffer(ownerPasswordEntry); - encDict.OE = wordArrayToBuffer(ownerEncryptionKeyEntry); - encDict.U = wordArrayToBuffer(userPasswordEntry); - encDict.UE = wordArrayToBuffer(userEncryptionKeyEntry); + encDict.O = Buffer.from(ownerPasswordEntry); + encDict.OE = Buffer.from(ownerEncryptionKeyEntry); + encDict.U = Buffer.from(userPasswordEntry); + encDict.UE = Buffer.from(userEncryptionKeyEntry); encDict.P = permissions; - encDict.Perms = wordArrayToBuffer(permsEntry); + encDict.Perms = Buffer.from(permsEntry); } getEncryptFn(obj, gen) { let digest; if (this.version < 5) { - digest = this.encryptionKey - .clone() - .concat( - CryptoJS.lib.WordArray.create( - [ - ((obj & 0xff) << 24) | - ((obj & 0xff00) << 8) | - ((obj >> 8) & 0xff00) | - (gen & 0xff), - (gen & 0xff00) << 16, - ], - 5, - ), - ); + // Create 5-byte object/generation number suffix + const suffix = new Uint8Array([ + obj & 0xff, + (obj >> 8) & 0xff, + (obj >> 16) & 0xff, + gen & 0xff, + (gen >> 8) & 0xff, + ]); + digest = concatBytes(this.encryptionKey, suffix); } if (this.version === 1 || this.version === 2) { - let key = CryptoJS.MD5(digest); - key.sigBytes = Math.min(16, this.keyBits / 8 + 5); - return (buffer) => - wordArrayToBuffer( - CryptoJS.RC4.encrypt(CryptoJS.lib.WordArray.create(buffer), key) - .ciphertext, - ); + let key = md5Hash(digest); + const keyLen = Math.min(16, this.keyBits / 8 + 5); + key = key.slice(0, keyLen); + return (buffer) => Buffer.from(rc4(new Uint8Array(buffer), key)); } let key; if (this.version === 4) { - key = CryptoJS.MD5( - digest.concat(CryptoJS.lib.WordArray.create([0x73416c54], 4)), - ); + // Append "sAlT" marker for AES + const saltMarker = new Uint8Array([0x73, 0x41, 0x6c, 0x54]); + key = md5Hash(concatBytes(digest, saltMarker)); } else { key = this.encryptionKey; } const iv = PDFSecurity.generateRandomWordArray(16); - const options = { - mode: CryptoJS.mode.CBC, - padding: CryptoJS.pad.Pkcs7, - iv, - }; - return (buffer) => - wordArrayToBuffer( - iv - .clone() - .concat( - CryptoJS.AES.encrypt( - CryptoJS.lib.WordArray.create(buffer), - key, - options, - ).ciphertext, - ), - ); + return (buffer) => { + const encrypted = aesCbcEncrypt(new Uint8Array(buffer), key, iv, true); + return Buffer.from(concatBytes(iv, encrypted)); + }; } end() { @@ -324,24 +302,25 @@ function getPermissionsR3(permissionObject = {}) { } function getUserPasswordR2(encryptionKey) { - return CryptoJS.RC4.encrypt(processPasswordR2R3R4(), encryptionKey) - .ciphertext; + return rc4(processPasswordR2R3R4(), encryptionKey); } function getUserPasswordR3R4(documentId, encryptionKey) { - const key = encryptionKey.clone(); - let cipher = CryptoJS.MD5( - processPasswordR2R3R4().concat(CryptoJS.lib.WordArray.create(documentId)), + const key = encryptionKey.slice(); + let cipher = md5Hash( + concatBytes(processPasswordR2R3R4(), new Uint8Array(documentId)), ); for (let i = 0; i < 20; i++) { - const xorRound = Math.ceil(key.sigBytes / 4); - for (let j = 0; j < xorRound; j++) { - key.words[j] = - encryptionKey.words[j] ^ (i | (i << 8) | (i << 16) | (i << 24)); + const xorKey = new Uint8Array(key.length); + for (let j = 0; j < key.length; j++) { + xorKey[j] = encryptionKey[j] ^ i; } - cipher = CryptoJS.RC4.encrypt(cipher, key).ciphertext; + cipher = rc4(cipher, xorKey); } - return cipher.concat(CryptoJS.lib.WordArray.create(null, 16)); + // Pad to 32 bytes + const result = new Uint8Array(32); + result.set(cipher); + return result; } function getOwnerPasswordR2R3R4( @@ -353,19 +332,19 @@ function getOwnerPasswordR2R3R4( let digest = paddedOwnerPassword; let round = r >= 3 ? 51 : 1; for (let i = 0; i < round; i++) { - digest = CryptoJS.MD5(digest); + digest = md5Hash(digest); } - const key = digest.clone(); - key.sigBytes = keyBits / 8; + const keyLen = keyBits / 8; + let key = digest.slice(0, keyLen); let cipher = paddedUserPassword; round = r >= 3 ? 20 : 1; for (let i = 0; i < round; i++) { - const xorRound = Math.ceil(key.sigBytes / 4); - for (let j = 0; j < xorRound; j++) { - key.words[j] = digest.words[j] ^ (i | (i << 8) | (i << 16) | (i << 24)); + const xorKey = new Uint8Array(keyLen); + for (let j = 0; j < keyLen; j++) { + xorKey[j] = key[j] ^ i; } - cipher = CryptoJS.RC4.encrypt(cipher, key).ciphertext; + cipher = rc4(cipher, xorKey); } return cipher; } @@ -378,15 +357,24 @@ function getEncryptionKeyR2R3R4( ownerPasswordEntry, permissions, ) { - let key = paddedUserPassword - .clone() - .concat(ownerPasswordEntry) - .concat(CryptoJS.lib.WordArray.create([lsbFirstWord(permissions)], 4)) - .concat(CryptoJS.lib.WordArray.create(documentId)); + // Build input: password + owner entry + permissions (LSB first) + document ID + const permBytes = new Uint8Array([ + permissions & 0xff, + (permissions >> 8) & 0xff, + (permissions >> 16) & 0xff, + (permissions >> 24) & 0xff, + ]); + let key = concatBytes( + paddedUserPassword, + ownerPasswordEntry, + permBytes, + new Uint8Array(documentId), + ); const round = r >= 3 ? 51 : 1; + const keyLen = keyBits / 8; for (let i = 0; i < round; i++) { - key = CryptoJS.MD5(key); - key.sigBytes = keyBits / 8; + key = md5Hash(key); + key = key.slice(0, keyLen); } return key; } @@ -394,9 +382,8 @@ function getEncryptionKeyR2R3R4( function getUserPasswordR5(processedUserPassword, generateRandomWordArray) { const validationSalt = generateRandomWordArray(8); const keySalt = generateRandomWordArray(8); - return CryptoJS.SHA256(processedUserPassword.clone().concat(validationSalt)) - .concat(validationSalt) - .concat(keySalt); + const hash = sha256Hash(concatBytes(processedUserPassword, validationSalt)); + return concatBytes(hash, validationSalt, keySalt); } function getUserEncryptionKeyR5( @@ -404,15 +391,9 @@ function getUserEncryptionKeyR5( userKeySalt, encryptionKey, ) { - const key = CryptoJS.SHA256( - processedUserPassword.clone().concat(userKeySalt), - ); - const options = { - mode: CryptoJS.mode.CBC, - padding: CryptoJS.pad.NoPadding, - iv: CryptoJS.lib.WordArray.create(null, 16), - }; - return CryptoJS.AES.encrypt(encryptionKey, key, options).ciphertext; + const key = sha256Hash(concatBytes(processedUserPassword, userKeySalt)); + const iv = new Uint8Array(16); // Zero IV + return aesCbcEncrypt(encryptionKey, key, iv, false); } function getOwnerPasswordR5( @@ -422,14 +403,10 @@ function getOwnerPasswordR5( ) { const validationSalt = generateRandomWordArray(8); const keySalt = generateRandomWordArray(8); - return CryptoJS.SHA256( - processedOwnerPassword - .clone() - .concat(validationSalt) - .concat(userPasswordEntry), - ) - .concat(validationSalt) - .concat(keySalt); + const hash = sha256Hash( + concatBytes(processedOwnerPassword, validationSalt, userPasswordEntry), + ); + return concatBytes(hash, validationSalt, keySalt); } function getOwnerEncryptionKeyR5( @@ -438,18 +415,11 @@ function getOwnerEncryptionKeyR5( userPasswordEntry, encryptionKey, ) { - const key = CryptoJS.SHA256( - processedOwnerPassword - .clone() - .concat(ownerKeySalt) - .concat(userPasswordEntry), + const key = sha256Hash( + concatBytes(processedOwnerPassword, ownerKeySalt, userPasswordEntry), ); - const options = { - mode: CryptoJS.mode.CBC, - padding: CryptoJS.pad.NoPadding, - iv: CryptoJS.lib.WordArray.create(null, 16), - }; - return CryptoJS.AES.encrypt(encryptionKey, key, options).ciphertext; + const iv = new Uint8Array(16); // Zero IV + return aesCbcEncrypt(encryptionKey, key, iv, false); } function getEncryptionKeyR5(generateRandomWordArray) { @@ -461,19 +431,32 @@ function getEncryptedPermissionsR5( encryptionKey, generateRandomWordArray, ) { - const cipher = CryptoJS.lib.WordArray.create( - [lsbFirstWord(permissions), 0xffffffff, 0x54616462], - 12, - ).concat(generateRandomWordArray(4)); - const options = { - mode: CryptoJS.mode.ECB, - padding: CryptoJS.pad.NoPadding, - }; - return CryptoJS.AES.encrypt(cipher, encryptionKey, options).ciphertext; + // Build 16-byte block: permissions (4 bytes LSB) + 0xFFFFFFFF (4 bytes) + "adbT" (4 bytes) + random (4 bytes) + const data = new Uint8Array(16); + // Permissions (LSB first) + data[0] = permissions & 0xff; + data[1] = (permissions >> 8) & 0xff; + data[2] = (permissions >> 16) & 0xff; + data[3] = (permissions >> 24) & 0xff; + // 0xFFFFFFFF + data[4] = 0xff; + data[5] = 0xff; + data[6] = 0xff; + data[7] = 0xff; + // "adbT" = 0x54616462 (but stored as individual bytes) + data[8] = 0x54; // 'T' + data[9] = 0x61; // 'a' + data[10] = 0x64; // 'd' + data[11] = 0x62; // 'b' + // Random 4 bytes + const randomPart = generateRandomWordArray(4); + data.set(randomPart, 12); + + return aesEcbEncrypt(data, encryptionKey); } function processPasswordR2R3R4(password = '') { - const out = Buffer.alloc(32); + const out = new Uint8Array(32); const length = password.length; let index = 0; while (index < length && index < 32) { @@ -488,38 +471,19 @@ function processPasswordR2R3R4(password = '') { out[index] = PASSWORD_PADDING[index - length]; index++; } - return CryptoJS.lib.WordArray.create(out); + return out; } function processPasswordR5(password = '') { password = unescape(encodeURIComponent(saslprep(password))); const length = Math.min(127, password.length); - const out = Buffer.alloc(length); + const out = new Uint8Array(length); for (let i = 0; i < length; i++) { out[i] = password.charCodeAt(i); } - return CryptoJS.lib.WordArray.create(out); -} - -function lsbFirstWord(data) { - return ( - ((data & 0xff) << 24) | - ((data & 0xff00) << 8) | - ((data >> 8) & 0xff00) | - ((data >> 24) & 0xff) - ); -} - -function wordArrayToBuffer(wordArray) { - const byteArray = []; - for (let i = 0; i < wordArray.sigBytes; i++) { - byteArray.push( - (wordArray.words[Math.floor(i / 4)] >> (8 * (3 - (i % 4)))) & 0xff, - ); - } - return Buffer.from(byteArray); + return out; } const PASSWORD_PADDING = [ diff --git a/package.json b/package.json index d488e834..172638d5 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,10 @@ "rollup-plugin-copy": "^3.5.0" }, "dependencies": { - "crypto-js": "^4.2.0", + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", + "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.0.0" }, diff --git a/rollup.config.js b/rollup.config.js index 7e20d154..10f8b17b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,7 +10,10 @@ const external = [ 'events', 'linebreak', 'png-js', - 'crypto-js', + 'js-md5', + '@noble/hashes/utils', + '@noble/hashes/sha256', + '@noble/ciphers/aes', 'saslprep' ]; diff --git a/tests/unit/crypto.spec.js b/tests/unit/crypto.spec.js new file mode 100644 index 00000000..27612dac --- /dev/null +++ b/tests/unit/crypto.spec.js @@ -0,0 +1,161 @@ +import { md5Hash, md5Hex } from '../../lib/crypto/md5'; +import sha256Hash from '../../lib/crypto/sha256'; +import { aesCbcEncrypt, aesEcbEncrypt } from '../../lib/crypto/aes'; +import rc4 from '../../lib/crypto/rc4'; +import randomBytes from '../../lib/crypto/random'; + +describe('crypto/md5', () => { + test('md5Hash returns correct hash for string', () => { + const result = md5Hash('hello'); + const hex = Array.from(result, (b) => b.toString(16).padStart(2, '0')).join( + '', + ); + expect(hex).toBe('5d41402abc4b2a76b9719d911017c592'); + }); + + test('md5Hash returns correct hash for Uint8Array', () => { + const input = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); // "hello" + const result = md5Hash(input); + const hex = Array.from(result, (b) => b.toString(16).padStart(2, '0')).join( + '', + ); + expect(hex).toBe('5d41402abc4b2a76b9719d911017c592'); + }); + + test('md5Hex returns hex string', () => { + expect(md5Hex('hello')).toBe('5d41402abc4b2a76b9719d911017c592'); + }); + + test('md5Hash empty string', () => { + const hex = md5Hex(''); + expect(hex).toBe('d41d8cd98f00b204e9800998ecf8427e'); + }); +}); + +describe('crypto/sha256', () => { + test('sha256Hash returns correct hash', () => { + const input = new TextEncoder().encode('hello'); + const result = sha256Hash(input); + const hex = Array.from(result, (b) => b.toString(16).padStart(2, '0')).join( + '', + ); + expect(hex).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + ); + }); + + test('sha256Hash empty input', () => { + const result = sha256Hash(new Uint8Array(0)); + const hex = Array.from(result, (b) => b.toString(16).padStart(2, '0')).join( + '', + ); + expect(hex).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); +}); + +describe('crypto/rc4', () => { + test('rc4 encrypts correctly with known test vector', () => { + // Test vector: Key = "Key", Plaintext = "Plaintext" + const key = new TextEncoder().encode('Key'); + const plaintext = new TextEncoder().encode('Plaintext'); + const ciphertext = rc4(plaintext, key); + + expect(ciphertext).toEqual( + new Uint8Array([0xbb, 0xf3, 0x16, 0xe8, 0xd9, 0x40, 0xaf, 0x0a, 0xd3]), + ); + }); + + test('rc4 is symmetric (encrypt then decrypt)', () => { + const key = new TextEncoder().encode('secret'); + const plaintext = new TextEncoder().encode('Hello, World!'); + + const encrypted = rc4(plaintext, key); + const decrypted = rc4(encrypted, key); + + expect(decrypted).toEqual(plaintext); + }); + + test('rc4 with different keys produces different output', () => { + const key1 = new TextEncoder().encode('key1'); + const key2 = new TextEncoder().encode('key2'); + const plaintext = new TextEncoder().encode('test'); + + const result1 = rc4(plaintext, key1); + const result2 = rc4(plaintext, key2); + + expect(result1).not.toEqual(result2); + }); +}); + +describe('crypto/aes', () => { + test('aesCbcEncrypt with padding', () => { + // AES-128-CBC test + const key = new Uint8Array(16).fill(0); // 16 zero bytes + const iv = new Uint8Array(16).fill(0); // 16 zero bytes + const plaintext = new TextEncoder().encode('hello'); // 5 bytes + + const result = aesCbcEncrypt(plaintext, key, iv, true); + expect(result).toBeInstanceOf(Uint8Array); + // With PKCS7 padding: 5 bytes -> 16 bytes (padded with 11 bytes of 0x0b) + expect(result.length).toBe(16); + }); + + test('aesCbcEncrypt without padding', () => { + const key = new Uint8Array(16).fill(0); + const iv = new Uint8Array(16).fill(0); + const plaintext = new Uint8Array(16).fill(0x41); // 16 'A's + + const result = aesCbcEncrypt(plaintext, key, iv, false); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(16); + }); + + test('aesCbcEncrypt with 256-bit key', () => { + const key = new Uint8Array(32).fill(0); // 32 zero bytes (AES-256) + const iv = new Uint8Array(16).fill(0); + const plaintext = new TextEncoder().encode('test message'); // 12 bytes + + const result = aesCbcEncrypt(plaintext, key, iv, true); + expect(result).toBeInstanceOf(Uint8Array); + // With PKCS7 padding: 12 bytes -> 16 bytes + expect(result.length).toBe(16); + }); + + test('aesEcbEncrypt', () => { + const key = new Uint8Array(16).fill(0); + const plaintext = new Uint8Array(16).fill(0); + + const result = aesEcbEncrypt(plaintext, key); + expect(result).toEqual( + // AES-128-ECB with all zeros: known result + new Uint8Array([ + 0x66, 0xe9, 0x4b, 0xd4, 0xef, 0x8a, 0x2c, 0x3b, 0x88, 0x4c, 0xfa, 0x59, + 0xca, 0x34, 0x2b, 0x2e, + ]), + ); + }); +}); + +describe('crypto/random', () => { + test('randomBytes returns Uint8Array of correct length', () => { + const result = randomBytes(16); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(16); + }); + + test('randomBytes returns different values each time', () => { + const result1 = randomBytes(16); + const result2 = randomBytes(16); + // Very unlikely to be equal + expect(result1).not.toEqual(result2); + }); + + test('randomBytes with various lengths', () => { + expect(randomBytes(0).length).toBe(0); + expect(randomBytes(1).length).toBe(1); + expect(randomBytes(32).length).toBe(32); + expect(randomBytes(100).length).toBe(100); + }); +}); diff --git a/tests/unit/security.spec.js b/tests/unit/security.spec.js new file mode 100644 index 00000000..48a7f183 --- /dev/null +++ b/tests/unit/security.spec.js @@ -0,0 +1,444 @@ +import PDFSecurity from '../../lib/security'; + +// Mock document object +function createMockDocument(id = null) { + const mockId = + id || + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + return { + _id: mockId, + ref: (obj) => ({ + data: obj, + end: jest.fn(), + }), + }; +} + +describe('PDFSecurity', () => { + describe('generateFileID', () => { + test('returns 16-byte Buffer', () => { + const info = { + CreationDate: new Date('2024-01-01T00:00:00Z'), + Title: 'Test', + }; + const result = PDFSecurity.generateFileID(info); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.length).toBe(16); + }); + + test('same input produces same output', () => { + const info = { + CreationDate: new Date('2024-01-01T00:00:00Z'), + Title: 'Test', + }; + const result1 = PDFSecurity.generateFileID(info); + const result2 = PDFSecurity.generateFileID(info); + expect(result1).toEqual(result2); + }); + + test('different input produces different output', () => { + const info1 = { + CreationDate: new Date('2024-01-01T00:00:00Z'), + Title: 'Test1', + }; + const info2 = { + CreationDate: new Date('2024-01-01T00:00:00Z'), + Title: 'Test2', + }; + const result1 = PDFSecurity.generateFileID(info1); + const result2 = PDFSecurity.generateFileID(info2); + expect(result1).not.toEqual(result2); + }); + }); + + describe('generateRandomWordArray', () => { + test('returns Uint8Array of correct length', () => { + const result = PDFSecurity.generateRandomWordArray(16); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(16); + }); + + test('returns different values each time', () => { + const result1 = PDFSecurity.generateRandomWordArray(16); + const result2 = PDFSecurity.generateRandomWordArray(16); + expect(result1).not.toEqual(result2); + }); + }); + + describe('create', () => { + test('returns null when no password provided', () => { + const doc = createMockDocument(); + const result = PDFSecurity.create(doc, {}); + expect(result).toBeNull(); + }); + + test('returns PDFSecurity instance when password provided', () => { + const doc = createMockDocument(); + const result = PDFSecurity.create(doc, { userPassword: 'test' }); + expect(result).toBeInstanceOf(PDFSecurity); + }); + }); + + describe('constructor', () => { + test('throws when no password provided', () => { + const doc = createMockDocument(); + expect(() => new PDFSecurity(doc, {})).toThrow( + 'None of owner password and user password is defined.', + ); + }); + }); + + describe('Version 1 (PDF 1.3, 40-bit RC4)', () => { + test('sets correct encryption parameters', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + ownerPassword: 'owner', + pdfVersion: '1.3', + }); + + expect(security.version).toBe(1); + expect(security.keyBits).toBe(40); + expect(security.dictionary.data.V).toBe(1); + expect(security.dictionary.data.R).toBe(2); + }); + + test('generates O and U entries', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + ownerPassword: 'owner', + pdfVersion: '1.3', + }); + + expect(Buffer.isBuffer(security.dictionary.data.O)).toBe(true); + expect(Buffer.isBuffer(security.dictionary.data.U)).toBe(true); + expect(security.dictionary.data.O.length).toBe(32); + expect(security.dictionary.data.U.length).toBe(32); + }); + + test('getEncryptFn returns function that encrypts data', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.3', + }); + + const encryptFn = security.getEncryptFn(1, 0); + expect(typeof encryptFn).toBe('function'); + + const plaintext = Buffer.from('Hello, World!'); + const encrypted = encryptFn(plaintext); + expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted.length).toBe(plaintext.length); + expect(encrypted).not.toEqual(plaintext); + }); + }); + + describe('Version 2 (PDF 1.4/1.5, 128-bit RC4)', () => { + test('sets correct encryption parameters for PDF 1.4', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + pdfVersion: '1.4', + }); + + expect(security.version).toBe(2); + expect(security.keyBits).toBe(128); + expect(security.dictionary.data.V).toBe(2); + expect(security.dictionary.data.R).toBe(3); + expect(security.dictionary.data.Length).toBe(128); + }); + + test('sets correct encryption parameters for PDF 1.5', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + pdfVersion: '1.5', + }); + + expect(security.version).toBe(2); + expect(security.keyBits).toBe(128); + }); + + test('getEncryptFn returns function that encrypts data', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.4', + }); + + const encryptFn = security.getEncryptFn(1, 0); + const plaintext = Buffer.from('Test data'); + const encrypted = encryptFn(plaintext); + + expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted).not.toEqual(plaintext); + }); + }); + + describe('Version 4 (PDF 1.6/1.7, 128-bit AES)', () => { + test('sets correct encryption parameters for PDF 1.6', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + pdfVersion: '1.6', + }); + + expect(security.version).toBe(4); + expect(security.keyBits).toBe(128); + expect(security.dictionary.data.V).toBe(4); + expect(security.dictionary.data.R).toBe(4); + expect(security.dictionary.data.CF.StdCF.CFM).toBe('AESV2'); + }); + + test('sets correct encryption parameters for PDF 1.7', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + pdfVersion: '1.7', + }); + + expect(security.version).toBe(4); + expect(security.dictionary.data.StmF).toBe('StdCF'); + expect(security.dictionary.data.StrF).toBe('StdCF'); + }); + + test('getEncryptFn returns function that encrypts with AES', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.6', + }); + + const encryptFn = security.getEncryptFn(1, 0); + const plaintext = Buffer.from('Test data for AES'); + const encrypted = encryptFn(plaintext); + + expect(Buffer.isBuffer(encrypted)).toBe(true); + // AES output includes 16-byte IV prefix + expect(encrypted.length).toBeGreaterThan(plaintext.length); + // First 16 bytes are IV + expect(encrypted.length).toBe(16 + 32); // IV + padded ciphertext + }); + }); + + describe('Version 5 (PDF 1.7ext3, 256-bit AES)', () => { + test('sets correct encryption parameters', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + ownerPassword: 'owner', + pdfVersion: '1.7ext3', + }); + + expect(security.version).toBe(5); + expect(security.keyBits).toBe(256); + expect(security.dictionary.data.V).toBe(5); + expect(security.dictionary.data.R).toBe(5); + expect(security.dictionary.data.CF.StdCF.CFM).toBe('AESV3'); + }); + + test('generates all required entries', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'user', + ownerPassword: 'owner', + pdfVersion: '1.7ext3', + }); + + expect(Buffer.isBuffer(security.dictionary.data.O)).toBe(true); + expect(Buffer.isBuffer(security.dictionary.data.U)).toBe(true); + expect(Buffer.isBuffer(security.dictionary.data.OE)).toBe(true); + expect(Buffer.isBuffer(security.dictionary.data.UE)).toBe(true); + expect(Buffer.isBuffer(security.dictionary.data.Perms)).toBe(true); + + expect(security.dictionary.data.O.length).toBe(48); + expect(security.dictionary.data.U.length).toBe(48); + expect(security.dictionary.data.OE.length).toBe(32); + expect(security.dictionary.data.UE.length).toBe(32); + expect(security.dictionary.data.Perms.length).toBe(16); + }); + + test('getEncryptFn returns function that encrypts with AES-256', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.7ext3', + }); + + const encryptFn = security.getEncryptFn(1, 0); + const plaintext = Buffer.from('Test data for AES-256'); + const encrypted = encryptFn(plaintext); + + expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted.length).toBeGreaterThan(plaintext.length); + }); + }); + + describe('Permissions', () => { + test('R2 permissions (PDF 1.3)', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.3', + permissions: { + printing: true, + modifying: true, + copying: true, + annotating: true, + }, + }); + + const p = security.dictionary.data.P; + expect(p & 0b000000000100).toBeTruthy(); // printing + expect(p & 0b000000001000).toBeTruthy(); // modifying + expect(p & 0b000000010000).toBeTruthy(); // copying + expect(p & 0b000000100000).toBeTruthy(); // annotating + }); + + test('R3 permissions (PDF 1.4+)', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.4', + permissions: { + printing: 'highResolution', + modifying: true, + copying: true, + annotating: true, + fillingForms: true, + contentAccessibility: true, + documentAssembly: true, + }, + }); + + const p = security.dictionary.data.P; + expect(p & 0b100000000100).toBeTruthy(); // high res printing + expect(p & 0b000000001000).toBeTruthy(); // modifying + expect(p & 0b000100000000).toBeTruthy(); // fillingForms + expect(p & 0b001000000000).toBeTruthy(); // contentAccessibility + expect(p & 0b010000000000).toBeTruthy(); // documentAssembly + }); + + test('low resolution printing', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.4', + permissions: { + printing: 'lowResolution', + }, + }); + + const p = security.dictionary.data.P; + expect(p & 0b000000000100).toBeTruthy(); // printing allowed + expect(p & 0b100000000000).toBeFalsy(); // but not high res + }); + }); + + describe('Password handling', () => { + test('user password only', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'userpass', + pdfVersion: '1.4', + }); + + expect(security.dictionary.data.O).toBeDefined(); + expect(security.dictionary.data.U).toBeDefined(); + }); + + test('owner password only', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + ownerPassword: 'ownerpass', + pdfVersion: '1.4', + }); + + expect(security.dictionary.data.O).toBeDefined(); + expect(security.dictionary.data.U).toBeDefined(); + }); + + test('both passwords', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'userpass', + ownerPassword: 'ownerpass', + pdfVersion: '1.4', + }); + + expect(security.dictionary.data.O).toBeDefined(); + expect(security.dictionary.data.U).toBeDefined(); + }); + + test('invalid password character throws', () => { + const doc = createMockDocument(); + expect(() => { + new PDFSecurity(doc, { + userPassword: 'password\u0100', // Character > 0xFF + pdfVersion: '1.3', + }); + }).toThrow('Password contains one or more invalid characters.'); + }); + + test('unicode password in R5', () => { + const doc = createMockDocument(); + // R5 supports Unicode via SASLprep + expect(() => { + new PDFSecurity(doc, { + userPassword: 'пароль', // Russian word for "password" + pdfVersion: '1.7ext3', + }); + }).not.toThrow(); + }); + }); + + describe('Encryption consistency', () => { + test('same password produces same encryption key', () => { + const doc1 = createMockDocument(); + const doc2 = createMockDocument(); + + const security1 = new PDFSecurity(doc1, { + userPassword: 'test', + pdfVersion: '1.4', + }); + const security2 = new PDFSecurity(doc2, { + userPassword: 'test', + pdfVersion: '1.4', + }); + + expect(security1.encryptionKey).toEqual(security2.encryptionKey); + }); + + test('different object numbers produce different encrypted output', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.4', + }); + + const encryptFn1 = security.getEncryptFn(1, 0); + const encryptFn2 = security.getEncryptFn(2, 0); + + const plaintext = Buffer.from('Same content'); + const encrypted1 = encryptFn1(plaintext); + const encrypted2 = encryptFn2(plaintext); + + expect(encrypted1).not.toEqual(encrypted2); + }); + }); + + describe('end', () => { + test('calls dictionary.end', () => { + const doc = createMockDocument(); + const security = new PDFSecurity(doc, { + userPassword: 'test', + pdfVersion: '1.4', + }); + + security.end(); + expect(security.dictionary.end).toHaveBeenCalled(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1c2560ba..4a258eab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1742,6 +1742,20 @@ __metadata: languageName: node linkType: hard +"@noble/ciphers@npm:^1.0.0": + version: 1.3.0 + resolution: "@noble/ciphers@npm:1.3.0" + checksum: 10c0/3ba6da645ce45e2f35e3b2e5c87ceba86b21dfa62b9466ede9edfb397f8116dae284f06652c0cd81d99445a2262b606632e868103d54ecc99fd946ae1af8cd37 + languageName: node + linkType: hard + +"@noble/hashes@npm:^1.6.0": + version: 1.8.0 + resolution: "@noble/hashes@npm:1.8.0" + checksum: 10c0/06a0b52c81a6fa7f04d67762e08b2c476a00285858150caeaaff4037356dd5e119f45b2a530f638b77a5eeca013168ec1b655db41bae3236cb2e9d511484fc77 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3136,13 +3150,6 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:^4.2.0": - version: 4.2.0 - resolution: "crypto-js@npm:4.2.0" - checksum: 10c0/8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0 - languageName: node - linkType: hard - "d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": version: 1.0.2 resolution: "d@npm:1.0.2" @@ -5324,6 +5331,13 @@ __metadata: languageName: node linkType: hard +"js-md5@npm:^0.8.3": + version: 0.8.3 + resolution: "js-md5@npm:0.8.3" + checksum: 10c0/f7e41e95f8e5eb5eeb43085bec3832ae3dfe0020c42fcca5a4efe571213391a9e9594db31bd34624b7280af4f1f12c751b6a50074a15346ecf40a0d54115d77f + languageName: node + linkType: hard + "js-stringify@npm:^1.0.2": version: 1.0.2 resolution: "js-stringify@npm:1.0.2" @@ -6303,6 +6317,8 @@ __metadata: "@babel/plugin-external-helpers": "npm:^7.25.9" "@babel/preset-env": "npm:^7.26.0" "@eslint/js": "npm:^9.17.0" + "@noble/ciphers": "npm:^1.0.0" + "@noble/hashes": "npm:^1.6.0" "@rollup/plugin-babel": "npm:^6.0.4" babel-jest: "npm:^29.7.0" blob-stream: "npm:^0.1.3" @@ -6311,13 +6327,13 @@ __metadata: browserify: "npm:^17.0.1" canvas: "npm:^3.2.0" codemirror: "npm:~5.65.18" - crypto-js: "npm:^4.2.0" eslint: "npm:^9.17.0" fontkit: "npm:^2.0.4" gh-pages: "npm:^6.2.0" globals: "npm:^15.14.0" jest: "npm:^29.7.0" jest-image-snapshot: "npm:^6.4.0" + js-md5: "npm:^0.8.3" linebreak: "npm:^1.1.0" markdown: "npm:~0.5.0" pdfjs-dist: "npm:^2.14.305"