Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/security/ciphers/aes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});

Expand Down
18 changes: 12 additions & 6 deletions src/security/ciphers/aes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -68,13 +68,19 @@ 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 aligned = ciphertext.length - (ciphertext.length % AES_BLOCK_SIZE);

if (aligned === 0) {
return new Uint8Array(0);
}

ciphertext = ciphertext.subarray(0, aligned);
}

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