Skip to content
Closed
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
175 changes: 175 additions & 0 deletions tests/Services/Base64ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
}
Loading