diff --git a/src/parser/document-parser.ts b/src/parser/document-parser.ts index 990e908..04b392d 100644 --- a/src/parser/document-parser.ts +++ b/src/parser/document-parser.ts @@ -534,65 +534,71 @@ export class DocumentParser { * Decrypt an object's strings and stream data. */ const decryptObject = (obj: PdfObject, objNum: number, genNum: number): PdfObject => { - if (!securityHandler?.isAuthenticated) { - return obj; - } - - if (obj instanceof PdfString) { - const decrypted = securityHandler.decryptString(obj.bytes, objNum, genNum); - - return new PdfString(decrypted, obj.format); - } + try { + if (!securityHandler?.isAuthenticated) { + return obj; + } - if (obj instanceof PdfArray) { - const decryptedItems: PdfObject[] = []; + if (obj instanceof PdfString) { + const decrypted = securityHandler.decryptString(obj.bytes, objNum, genNum); - for (const item of obj) { - decryptedItems.push(decryptObject(item, objNum, genNum)); + return new PdfString(decrypted, obj.format); } - return new PdfArray(decryptedItems); - } + if (obj instanceof PdfArray) { + const decryptedItems: PdfObject[] = []; - // Check PdfStream BEFORE PdfDict (PdfStream extends PdfDict) - if (obj instanceof PdfStream) { - // Check if this stream should be encrypted - const streamType = obj.getName("Type")?.value; + for (const item of obj) { + decryptedItems.push(decryptObject(item, objNum, genNum)); + } - if (!securityHandler.shouldEncryptStream(streamType)) { - return obj; + return new PdfArray(decryptedItems); } - // Decrypt stream data - const decryptedData = securityHandler.decryptStream(obj.data, objNum, genNum); + // Check PdfStream BEFORE PdfDict (PdfStream extends PdfDict) + if (obj instanceof PdfStream) { + // Check if this stream should be encrypted + const streamType = obj.getName("Type")?.value; + + if (!securityHandler.shouldEncryptStream(streamType)) { + return obj; + } + + // Decrypt stream data + const decryptedData = securityHandler.decryptStream(obj.data, objNum, genNum); - // Create new stream with decrypted data - // Copy dictionary entries (strings in dict will be decrypted when accessed) - const newStream = new PdfStream(obj, decryptedData); + // Create new stream with decrypted data + // Copy dictionary entries (strings in dict will be decrypted when accessed) + const newStream = new PdfStream(obj, decryptedData); - // Decrypt strings in the dictionary entries - for (const [key, value] of obj) { - const decryptedValue = decryptObject(value, objNum, genNum); + // Decrypt strings in the dictionary entries + for (const [key, value] of obj) { + const decryptedValue = decryptObject(value, objNum, genNum); - if (decryptedValue !== value) { - newStream.set(key.value, decryptedValue); + if (decryptedValue !== value) { + newStream.set(key.value, decryptedValue); + } } + + return newStream; } - return newStream; - } + if (obj instanceof PdfDict) { + const decryptedDict = new PdfDict(); - if (obj instanceof PdfDict) { - const decryptedDict = new PdfDict(); + for (const [key, value] of obj) { + decryptedDict.set(key.value, decryptObject(value, objNum, genNum)); + } - for (const [key, value] of obj) { - decryptedDict.set(key.value, decryptObject(value, objNum, genNum)); + return decryptedDict; } - return decryptedDict; - } + return obj; + } catch (error) { + console.warn(`Failed to decrypt object ${objNum} ${genNum}:`, error); - return obj; + return obj; + } }; const getObject = (ref: PdfRef): PdfObject | null => { diff --git a/src/security/ciphers/aes.test.ts b/src/security/ciphers/aes.test.ts index 4540475..b6a7641 100644 --- a/src/security/ciphers/aes.test.ts +++ b/src/security/ciphers/aes.test.ts @@ -168,11 +168,27 @@ describe("AES-CBC encryption", () => { expect(() => aesEncryptWithIv(key128, iv, new Uint8Array(16))).toThrow(/IV must be 16/); }); - it("should reject non-block-aligned ciphertext", () => { - // IV (16) + non-aligned ciphertext (10) - const invalidData = new Uint8Array(26); + it("should truncate non-block-aligned ciphertext to nearest boundary", () => { + // Encrypt known plaintext, then append extra bytes to misalign + const plaintext = new TextEncoder().encode("aligned block!??"); // 16 bytes = 1 block + const encrypted = aesEncrypt(key128, plaintext); + + // Append 5 garbage bytes to make ciphertext non-block-aligned + const misaligned = new Uint8Array(encrypted.length + 5); + misaligned.set(encrypted); + misaligned.set([0xde, 0xad, 0xbe, 0xef, 0x42], encrypted.length); + + // Should not throw — truncates trailing bytes and decrypts what fits + const decrypted = aesDecrypt(key128, misaligned); + expect(decrypted).toEqual(plaintext); + }); - expect(() => aesDecrypt(key128, invalidData)).toThrow(/must be multiple of 16/); + it("should return empty for non-block-aligned ciphertext shorter than one block", () => { + // IV (16) + 10 bytes (less than one block) + const data = new Uint8Array(26); + + const result = aesDecrypt(key128, data); + expect(result).toEqual(new Uint8Array(0)); }); }); diff --git a/src/security/ciphers/aes.ts b/src/security/ciphers/aes.ts index 2a44128..5578c8d 100644 --- a/src/security/ciphers/aes.ts +++ b/src/security/ciphers/aes.ts @@ -52,7 +52,7 @@ export function aesEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array { * @param key - 16 bytes (AES-128) or 32 bytes (AES-256) * @param data - IV (16 bytes) + ciphertext * @returns Decrypted plaintext - * @throws {Error} if data is too short or padding is invalid + * @throws {Error} if data is too short to contain an IV */ export function aesDecrypt(key: Uint8Array, data: Uint8Array): Uint8Array { validateAesKey(key); @@ -68,13 +68,24 @@ export function aesDecrypt(key: Uint8Array, data: Uint8Array): Uint8Array { // Extract IV and ciphertext const iv = data.subarray(0, AES_BLOCK_SIZE); - const ciphertext = data.subarray(AES_BLOCK_SIZE); + let ciphertext = data.subarray(AES_BLOCK_SIZE); - // Ciphertext must be multiple of block size + // Truncate to nearest block boundary for misaligned ciphertext. + // This recovers as much data as possible from corrupted encrypted PDFs + // (e.g., buggy generators that didn't properly pad before encryption). if (ciphertext.length % AES_BLOCK_SIZE !== 0) { - throw new Error( - `AES ciphertext length must be multiple of ${AES_BLOCK_SIZE}, got ${ciphertext.length}`, + const remainder = ciphertext.length % AES_BLOCK_SIZE; + const aligned = ciphertext.length - remainder; + + console.warn( + `AES ciphertext length (${ciphertext.length}) is not a multiple of ${AES_BLOCK_SIZE}, truncating ${remainder} trailing bytes`, ); + + if (aligned === 0) { + return new Uint8Array(0); + } + + ciphertext = ciphertext.subarray(0, aligned); } // Decrypt with CBC mode (PKCS#7 padding removed automatically)