Skip to content

Commit fde3b13

Browse files
authored
fix(security): truncate misaligned AES ciphertext instead of throwing (#29)
1 parent a52b6cf commit fde3b13

File tree

3 files changed

+82
-49
lines changed

3 files changed

+82
-49
lines changed

src/parser/document-parser.ts

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -534,65 +534,71 @@ export class DocumentParser {
534534
* Decrypt an object's strings and stream data.
535535
*/
536536
const decryptObject = (obj: PdfObject, objNum: number, genNum: number): PdfObject => {
537-
if (!securityHandler?.isAuthenticated) {
538-
return obj;
539-
}
540-
541-
if (obj instanceof PdfString) {
542-
const decrypted = securityHandler.decryptString(obj.bytes, objNum, genNum);
543-
544-
return new PdfString(decrypted, obj.format);
545-
}
537+
try {
538+
if (!securityHandler?.isAuthenticated) {
539+
return obj;
540+
}
546541

547-
if (obj instanceof PdfArray) {
548-
const decryptedItems: PdfObject[] = [];
542+
if (obj instanceof PdfString) {
543+
const decrypted = securityHandler.decryptString(obj.bytes, objNum, genNum);
549544

550-
for (const item of obj) {
551-
decryptedItems.push(decryptObject(item, objNum, genNum));
545+
return new PdfString(decrypted, obj.format);
552546
}
553547

554-
return new PdfArray(decryptedItems);
555-
}
548+
if (obj instanceof PdfArray) {
549+
const decryptedItems: PdfObject[] = [];
556550

557-
// Check PdfStream BEFORE PdfDict (PdfStream extends PdfDict)
558-
if (obj instanceof PdfStream) {
559-
// Check if this stream should be encrypted
560-
const streamType = obj.getName("Type")?.value;
551+
for (const item of obj) {
552+
decryptedItems.push(decryptObject(item, objNum, genNum));
553+
}
561554

562-
if (!securityHandler.shouldEncryptStream(streamType)) {
563-
return obj;
555+
return new PdfArray(decryptedItems);
564556
}
565557

566-
// Decrypt stream data
567-
const decryptedData = securityHandler.decryptStream(obj.data, objNum, genNum);
558+
// Check PdfStream BEFORE PdfDict (PdfStream extends PdfDict)
559+
if (obj instanceof PdfStream) {
560+
// Check if this stream should be encrypted
561+
const streamType = obj.getName("Type")?.value;
562+
563+
if (!securityHandler.shouldEncryptStream(streamType)) {
564+
return obj;
565+
}
566+
567+
// Decrypt stream data
568+
const decryptedData = securityHandler.decryptStream(obj.data, objNum, genNum);
568569

569-
// Create new stream with decrypted data
570-
// Copy dictionary entries (strings in dict will be decrypted when accessed)
571-
const newStream = new PdfStream(obj, decryptedData);
570+
// Create new stream with decrypted data
571+
// Copy dictionary entries (strings in dict will be decrypted when accessed)
572+
const newStream = new PdfStream(obj, decryptedData);
572573

573-
// Decrypt strings in the dictionary entries
574-
for (const [key, value] of obj) {
575-
const decryptedValue = decryptObject(value, objNum, genNum);
574+
// Decrypt strings in the dictionary entries
575+
for (const [key, value] of obj) {
576+
const decryptedValue = decryptObject(value, objNum, genNum);
576577

577-
if (decryptedValue !== value) {
578-
newStream.set(key.value, decryptedValue);
578+
if (decryptedValue !== value) {
579+
newStream.set(key.value, decryptedValue);
580+
}
579581
}
582+
583+
return newStream;
580584
}
581585

582-
return newStream;
583-
}
586+
if (obj instanceof PdfDict) {
587+
const decryptedDict = new PdfDict();
584588

585-
if (obj instanceof PdfDict) {
586-
const decryptedDict = new PdfDict();
589+
for (const [key, value] of obj) {
590+
decryptedDict.set(key.value, decryptObject(value, objNum, genNum));
591+
}
587592

588-
for (const [key, value] of obj) {
589-
decryptedDict.set(key.value, decryptObject(value, objNum, genNum));
593+
return decryptedDict;
590594
}
591595

592-
return decryptedDict;
593-
}
596+
return obj;
597+
} catch (error) {
598+
console.warn(`Failed to decrypt object ${objNum} ${genNum}:`, error);
594599

595-
return obj;
600+
return obj;
601+
}
596602
};
597603

598604
const getObject = (ref: PdfRef): PdfObject | null => {

src/security/ciphers/aes.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,27 @@ describe("AES-CBC encryption", () => {
168168
expect(() => aesEncryptWithIv(key128, iv, new Uint8Array(16))).toThrow(/IV must be 16/);
169169
});
170170

171-
it("should reject non-block-aligned ciphertext", () => {
172-
// IV (16) + non-aligned ciphertext (10)
173-
const invalidData = new Uint8Array(26);
171+
it("should truncate non-block-aligned ciphertext to nearest boundary", () => {
172+
// Encrypt known plaintext, then append extra bytes to misalign
173+
const plaintext = new TextEncoder().encode("aligned block!??"); // 16 bytes = 1 block
174+
const encrypted = aesEncrypt(key128, plaintext);
175+
176+
// Append 5 garbage bytes to make ciphertext non-block-aligned
177+
const misaligned = new Uint8Array(encrypted.length + 5);
178+
misaligned.set(encrypted);
179+
misaligned.set([0xde, 0xad, 0xbe, 0xef, 0x42], encrypted.length);
180+
181+
// Should not throw — truncates trailing bytes and decrypts what fits
182+
const decrypted = aesDecrypt(key128, misaligned);
183+
expect(decrypted).toEqual(plaintext);
184+
});
174185

175-
expect(() => aesDecrypt(key128, invalidData)).toThrow(/must be multiple of 16/);
186+
it("should return empty for non-block-aligned ciphertext shorter than one block", () => {
187+
// IV (16) + 10 bytes (less than one block)
188+
const data = new Uint8Array(26);
189+
190+
const result = aesDecrypt(key128, data);
191+
expect(result).toEqual(new Uint8Array(0));
176192
});
177193
});
178194

src/security/ciphers/aes.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function aesEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array {
5252
* @param key - 16 bytes (AES-128) or 32 bytes (AES-256)
5353
* @param data - IV (16 bytes) + ciphertext
5454
* @returns Decrypted plaintext
55-
* @throws {Error} if data is too short or padding is invalid
55+
* @throws {Error} if data is too short to contain an IV
5656
*/
5757
export function aesDecrypt(key: Uint8Array, data: Uint8Array): Uint8Array {
5858
validateAesKey(key);
@@ -68,13 +68,24 @@ export function aesDecrypt(key: Uint8Array, data: Uint8Array): Uint8Array {
6868

6969
// Extract IV and ciphertext
7070
const iv = data.subarray(0, AES_BLOCK_SIZE);
71-
const ciphertext = data.subarray(AES_BLOCK_SIZE);
71+
let ciphertext = data.subarray(AES_BLOCK_SIZE);
7272

73-
// Ciphertext must be multiple of block size
73+
// Truncate to nearest block boundary for misaligned ciphertext.
74+
// This recovers as much data as possible from corrupted encrypted PDFs
75+
// (e.g., buggy generators that didn't properly pad before encryption).
7476
if (ciphertext.length % AES_BLOCK_SIZE !== 0) {
75-
throw new Error(
76-
`AES ciphertext length must be multiple of ${AES_BLOCK_SIZE}, got ${ciphertext.length}`,
77+
const remainder = ciphertext.length % AES_BLOCK_SIZE;
78+
const aligned = ciphertext.length - remainder;
79+
80+
console.warn(
81+
`AES ciphertext length (${ciphertext.length}) is not a multiple of ${AES_BLOCK_SIZE}, truncating ${remainder} trailing bytes`,
7782
);
83+
84+
if (aligned === 0) {
85+
return new Uint8Array(0);
86+
}
87+
88+
ciphertext = ciphertext.subarray(0, aligned);
7889
}
7990

8091
// Decrypt with CBC mode (PKCS#7 padding removed automatically)

0 commit comments

Comments
 (0)