amzDate = gmdate('Ymd\THis\Z'); $this->shortDate = substr($this->amzDate, 0, 8); $this->scope = $this->shortDate . '/' . self::REGION . '/s3/aws4_request'; $this->key = SigningKey::derive(self::SECRET, $this->shortDate, self::REGION, 's3'); $this->seed = hash('sha256', 'seed-signature'); } public function testSignedMultiChunkDecodes(): void { $body = $this->signedBody(['hello ', 'world'], trailer: false); [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); $decoded = stream_get_contents($stream); fclose($stream); self::assertSame('hello world', $decoded); ChunkedDecoder::assertValid($state, 11); // no exception } public function testSignedChunkWithTrailerDecodes(): void { $body = $this->signedBody(['payload-bytes'], trailer: true); [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); $decoded = stream_get_contents($stream); fclose($stream); self::assertSame('payload-bytes', $decoded); ChunkedDecoder::assertValid($state, 13); } public function testUnsignedTrailerFramingDecodes(): void { $body = "6\r\nabcdef\r\n5\r\n12345\r\n0\r\nx-amz-checksum-crc32:abcd1234\r\n\r\n"; [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null); $decoded = stream_get_contents($stream); fclose($stream); self::assertSame('abcdef12345', $decoded); ChunkedDecoder::assertValid($state, 11); } public function testTamperedChunkDataRejected(): void { $body = $this->signedBody(['hello ', 'world'], trailer: false); $body = str_replace('world', 'WORLD', $body); [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); stream_get_contents($stream); fclose($stream); try { ChunkedDecoder::assertValid($state, 11); self::fail('expected SignatureDoesNotMatch'); } catch (S3Exception $e) { self::assertSame('SignatureDoesNotMatch', $e->errorCode); } } public function testTamperedFinalChunkSignatureRejected(): void { $body = $this->signedBody(['hello'], trailer: false); $body = preg_replace('/0;chunk-signature=[0-9a-f]{64}/', '0;chunk-signature=' . str_repeat('0', 64), $body); [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); stream_get_contents($stream); fclose($stream); try { ChunkedDecoder::assertValid($state, 5); self::fail('expected SignatureDoesNotMatch'); } catch (S3Exception $e) { self::assertSame('SignatureDoesNotMatch', $e->errorCode); } } public function testMissingChunkSignatureInSignedModeRejected(): void { $body = "5\r\nhello\r\n0;chunk-signature=" . hash('sha256', 'x') . "\r\n\r\n"; [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); stream_get_contents($stream); fclose($stream); self::assertSame('SignatureDoesNotMatch', $state->error); } public function testTruncatedStreamRejected(): void { $body = $this->signedBody(['hello'], trailer: false); $body = substr($body, 0, (int) (strpos($body, "\r\n0;") ?: 10) + 3); // cut before terminal chunk [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); stream_get_contents($stream); fclose($stream); self::assertNotNull($state->error); self::assertStringContainsString('IncompleteBody', $state->error); } public function testWrongDeclaredLengthRejected(): void { $body = $this->signedBody(['hello'], trailer: false); [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed); stream_get_contents($stream); fclose($stream); try { ChunkedDecoder::assertValid($state, 999); self::fail('expected IncompleteBody'); } catch (S3Exception $e) { self::assertSame('IncompleteBody', $e->errorCode); } } public function testMalformedChunkHeaderRejected(): void { $body = "not-hex-size\r\ndata\r\n"; [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null); stream_get_contents($stream); fclose($stream); self::assertNotNull($state->error); } public function testChunkSizeOverflowRejected(): void { // 16 hex digits exceed PHP_INT_MAX; the size must be rejected rather // than overflowing the int cast and misparsing the remainder. $body = "ffffffffffffffff\r\ndata\r\n"; [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null); stream_get_contents($stream); fclose($stream); self::assertNotNull($state->error); self::assertStringContainsString('malformed chunk header', $state->error); } public function testSignedChunkWithByteByByteDelivery(): void { if (!in_array('test_byte_filter', stream_get_filters(), true)) { stream_filter_register('test_byte_filter', ChunkedTestByteFilter::class); } $body = $this->signedBody(['hello ', 'world'], trailer: false); $auth = new AuthContext( accessKeyId: 'AKIAEXAMPLEKEY000EX', secret: self::SECRET, ownerId: 1, allowedBuckets: null, region: self::REGION, shortDate: $this->shortDate, payloadHash: self::PAYLOAD_SIGNED, isPresigned: false, chunkSeedSignature: $this->seed, amzDate: $this->amzDate, ); $raw = fopen('php://temp', 'r+b'); fwrite($raw, $body); rewind($raw); stream_filter_append($raw, 'test_byte_filter', STREAM_FILTER_READ); [$stream, $state] = ChunkedDecoder::wrap($raw, $auth); $decoded = stream_get_contents($stream); fclose($stream); self::assertSame('hello world', $decoded); ChunkedDecoder::assertValid($state, 11); } /* --------------------------------------------------------- helpers */ /** @param list $parts */ private function signedBody(array $parts, bool $trailer): string { $prev = $this->seed; $out = ''; foreach ($parts as $data) { $sig = $this->chunkSig($data, $prev); $out .= sprintf('%x;chunk-signature=%s', strlen($data), $sig) . "\r\n" . $data . "\r\n"; $prev = $sig; } $final = $this->chunkSig('', $prev); $out .= '0;chunk-signature=' . $final . "\r\n"; if ($trailer) { $out .= "x-amz-checksum-crc32:abcd1234\r\n"; } return $out . "\r\n"; } private function chunkSig(string $data, string $prev): string { $stringToSign = "AWS4-HMAC-SHA256-PAYLOAD\n" . $this->amzDate . "\n" . $this->scope . "\n" . $prev . "\n" . hash('sha256', '') . "\n" . hash('sha256', $data); return hash_hmac('sha256', $stringToSign, $this->key); } /** @return array{0: resource, 1: \stdClass} */ private function wrap(string $body, string $payloadHash, ?string $seed): array { $auth = new AuthContext( accessKeyId: 'AKIAEXAMPLEKEY000EX', secret: self::SECRET, ownerId: 1, allowedBuckets: null, region: self::REGION, shortDate: $this->shortDate, payloadHash: $payloadHash, isPresigned: false, chunkSeedSignature: $seed, amzDate: $this->amzDate, ); $stream = fopen('php://temp', 'r+b'); fwrite($stream, $body); rewind($stream); [$stream, $state] = ChunkedDecoder::wrap($stream, $auth); return [$stream, $state]; } } /** * Splits stream data into 1-byte buckets to test filter resilience against packet fragmentation. */ final class ChunkedTestByteFilter extends \php_user_filter { #[\Override] public function filter($in, $out, &$consumed, bool $closing): int { while ($bucket = stream_bucket_make_writeable($in)) { $data = (string) $bucket->data; $len = strlen($data); $consumed += $len; for ($i = 0; $i < $len; $i++) { $b = stream_bucket_new($this->stream, $data[$i]); stream_bucket_append($out, $b); } } return PSFS_PASS_ON; } }