diff --git a/tests/Services/Base64ServiceTest.php b/tests/Services/Base64ServiceTest.php index f4d0d26..5f4dbb9 100644 --- a/tests/Services/Base64ServiceTest.php +++ b/tests/Services/Base64ServiceTest.php @@ -3,6 +3,7 @@ namespace EbicsApi\Ebics\Tests\Services; use EbicsApi\Ebics\Contracts\BufferInterface; +use EbicsApi\Ebics\Factories\Crypt\AESFactory; use EbicsApi\Ebics\Models\Buffer; use EbicsApi\Ebics\Services\Base64Service; use PHPUnit\Framework\Attributes\DataProvider; @@ -23,6 +24,26 @@ protected function setUp(): void $this->base64Service = new Base64Service(); } + private function createBufferFromString(string $content): Buffer + { + $filename = tempnam(sys_get_temp_dir(), 'ebics_base64_test_'); + file_put_contents($filename, $content); + $buffer = new Buffer($filename); + $buffer->open('r'); + $buffer->rewind(); + + return $buffer; + } + + private function createEmptyBuffer(): Buffer + { + $filename = tempnam(sys_get_temp_dir(), 'ebics_base64_test_'); + $buffer = new Buffer($filename); + $buffer->open('w+'); + + return $buffer; + } + public function testEncodeReturnsBase64String(): void { $data = 'Hello World'; @@ -521,4 +542,158 @@ public static function encodeBufferWithVariousSizesProvider(): array '10000 bytes' => [10000], ]; } + + /** + * Core correctness check: decodeBuffer() must match base64_decode() on the + * exact same input for every size listed. + * + * We use unpadded base64 (rtrim(..., '=')) because that is the realistic + * EBICS scenario where a trailing group of 1–3 characters can appear. + */ + #[DataProvider('variousSizesProvider')] + public function testDecodeBufferMatchesOneshotBase64Decode(int $rawSize): void + { + $original = random_bytes($rawSize); + $unpaddedBase64 = rtrim(base64_encode($original), '='); + + $input = $this->createBufferFromString($unpaddedBase64); + $output = $this->createEmptyBuffer(); + + $this->base64Service->decodeBuffer($input, $output); + + $output->rewind(); + $decoded = $output->readContent(); + + // Reference: native base64_decode on the exact same string. + $expected = base64_decode($unpaddedBase64); + + self::assertEquals( + $expected, + $decoded, + sprintf( + "decodeBuffer() output (%d bytes) differs from base64_decode() (%d bytes) for rawSize=%d, b64Len=%d, b64Mod4=%d", + strlen($decoded), + strlen($expected), + $rawSize, + strlen($unpaddedBase64), + strlen($unpaddedBase64) % 4 + ) + ); + } + + public static function variousSizesProvider(): array + { + return [ + // Small remainders that never cross a chunk boundary + '1 byte raw' => [1], + '2 bytes raw' => [2], + '3 bytes raw' => [3], + + // Sizes where base64 length mod 4 == 0 (clean groups) + '6 bytes raw (b64 mod4=0)' => [6], + '1023 bytes raw (b64 mod4=0)' => [1023], + + // Sizes that produce a base64 remainder of 1–3 at EOF + '7 bytes raw (b64 mod4=3)' => [7], + '8 bytes raw (b64 mod4=2)' => [8], + '9 bytes raw (b64 mod4=1)' => [9], + + // Sizes around the internal 1024-byte read chunk + '768 bytes raw (b64 = 1024 chars)' => [768], + '1024 bytes raw' => [1024], + '1025 bytes raw' => [1025], + + // Large sizes that cross multiple chunk boundaries + '2000 bytes raw' => [2000], + '5000 bytes raw' => [5000], + ]; + } + + /** + * Demonstrate the bug that occurs when base64_decode() is applied to + * fixed-size chunks without carrying a remainder across boundaries. + * + * Because base64 groups are 4 characters, a chunk size that is NOT a + * multiple of 4 (e.g. 1023) cuts through a group at every boundary. + * The leftover 1–3 characters at the end of each chunk are decoded + * independently and therefore produce incorrect / truncated bytes. + * + * The current Base64Service avoids this by accumulating $remainder and + * prepending it to the next chunk, so group boundaries are never split. + */ + public function testChunkedDecodeWithoutRemainderLosesBytesWhenChunkSizeNotAligned(): void + { + $original = random_bytes(1023); // unpadded b64 length = 1364 + $unpaddedBase64 = rtrim(base64_encode($original), '='); + + $chunkSize = 1023; // NOT a multiple of 4 → group boundaries are split + + $buggyDecoded = ''; + $pos = 0; + $len = strlen($unpaddedBase64); + while ($pos < $len) { + $chunk = substr($unpaddedBase64, $pos, $chunkSize); + $buggyDecoded .= base64_decode($chunk); + $pos += $chunkSize; + } + + $correctDecoded = base64_decode($unpaddedBase64); + + self::assertNotEquals( + $correctDecoded, + $buggyDecoded, + "Chunked decode without remainder must lose bytes when chunk size ($chunkSize) is not a multiple of 4" + ); + } + + /** + * BUG: truncated ciphertext after buggy chunked base64 decode causes + * AES::unpad() to throw LogicException: "Length incorrect." + * + * Flow: + * 1. Encrypt a plaintext with AES-128-CBC. + * 2. Encode the ciphertext to unpadded base64. + * 3. Decode with the buggy no-remainder approach (chunk size 1023). + * This drops 1-3 bytes from the ciphertext. + * 4. Feed the truncated ciphertext to AES::decryptBuffer(). + * 5. The ciphertext is no longer a multiple of 16, so unpad() fails. + */ + public function testBuggyChunkedBase64DecodeCorruptsAesCiphertext(): void + { + $aesFactory = new AESFactory(); + $aes = $aesFactory->create(); + $aes->setKeyLength(128); + $key = str_repeat('K', 16); + $aes->setKey($key); + $aes->setOpenSSLOptions(OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING); + + $plaintext = str_repeat('A', 2000); // 2000 bytes → padded to 2000+16 = 2016-byte ciphertext + $ciphertext = $aes->encrypt($plaintext); + self::assertGreaterThanOrEqual(1025, strlen($ciphertext)); + + $unpaddedBase64 = rtrim(base64_encode($ciphertext), '='); + self::assertGreaterThan(1024, strlen($unpaddedBase64), 'Need b64 > 1024 to span multiple chunks'); + + // Buggy decode: chunk size NOT a multiple of 4 → bytes lost at every boundary + $buggyCiphertext = ''; + $pos = 0; + $len = strlen($unpaddedBase64); + $chunkSize = 1023; + while ($pos < $len) { + $chunk = substr($unpaddedBase64, $pos, $chunkSize); + $buggyCiphertext .= base64_decode($chunk); + $pos += $chunkSize; + } + + // The buggy ciphertext is shorter → no longer a block-aligned size + self::assertLessThan(strlen($ciphertext), strlen($buggyCiphertext)); + + $cipherBuffer = $this->createBufferFromString($buggyCiphertext); + $plainBuffer = $this->createEmptyBuffer(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Length incorrect.'); + + $aes->decryptBuffer($cipherBuffer, $plainBuffer); + } }