From 588a15d17f974643edb31c6da59ce91695a69bdb Mon Sep 17 00:00:00 2001 From: Thiery Laverdure Date: Tue, 11 Nov 2025 17:37:42 -0500 Subject: [PATCH 1/3] Add chunked signature support for streaming uploads Introduces ChunkedSignatureSigner for LQTP chunked signature validation, enabling secure streaming uploads with per-chunk signatures. Updates Connection and HttpStreamingTransport to use the new signer when access key credentials are provided, and adjusts request signing to support a streaming payload marker. Includes comprehensive tests for chunked signature logic. --- src/ChunkedSignatureSigner.php | 101 +++++++++++++++ src/Connection.php | 42 ++++-- src/HttpStreamingTransport.php | 23 +++- src/Middleware/AuthMiddleware.php | 5 +- src/RequestSigner.php | 14 +- src/SignsRequests.php | 3 +- tests/ChunkedSignatureSignerTest.php | 185 +++++++++++++++++++++++++++ 7 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 src/ChunkedSignatureSigner.php create mode 100644 tests/ChunkedSignatureSignerTest.php diff --git a/src/ChunkedSignatureSigner.php b/src/ChunkedSignatureSigner.php new file mode 100644 index 0000000..5c823b7 --- /dev/null +++ b/src/ChunkedSignatureSigner.php @@ -0,0 +1,101 @@ +accessKeySecret = $accessKeySecret; + $this->date = $date; + $this->previousSignature = $seedSignature; + } + + /** + * Sign a chunk of data and return the signature. + * + * Signature calculation (LQTP protocol): + * 1. Hash the chunk data: chunkHash = SHA256(chunkData) + * 2. Create string to sign: stringToSign = previousSignature + chunkHash + * 3. Generate signing key chain: + * - dateKey = HMAC-SHA256(accessKeySecret, date) + * - serviceKey = HMAC-SHA256(dateKey, "litebase_request") + * 4. Sign: signature = HMAC-SHA256(serviceKey, stringToSign) + * + * The signature chains ensure chunks are sent in the correct order and prevents tampering. + * + * @param string $chunkData The raw chunk data to sign + * @return string The hex-encoded signature + */ + public function signChunk(string $chunkData): string + { + // Calculate the hash of the chunk data + $chunkHash = hash('sha256', $chunkData); + + // Create the string to sign for this chunk + // Format: previousSignature + chunkHash + $stringToSign = $this->previousSignature . $chunkHash; + + // Create the signing key chain (same as in request signature validation) + $dateKey = hash_hmac('sha256', $this->date, $this->accessKeySecret); + $serviceKey = hash_hmac('sha256', 'litebase_request', $dateKey); + + // Sign the chunk + $signature = hash_hmac('sha256', $stringToSign, $serviceKey); + + // Update the previous signature for the next chunk + $this->previousSignature = $signature; + + return $signature; + } + + /** + * Get the current previous signature (for testing/debugging). + */ + public function getPreviousSignature(): string + { + return $this->previousSignature; + } + + /** + * Extract the signature from a base64 encoded authorization token. + * + * @param string $token The base64 encoded token + * @return string|null The extracted signature or null if not found + */ + public static function extractSignatureFromToken(string $token): ?string + { + $decoded = base64_decode($token, true); + + if ($decoded === false) { + return null; + } + + $parts = explode(';', $decoded); + + foreach ($parts as $part) { + if (str_starts_with($part, 'signature=')) { + return substr($part, strlen('signature=')); + } + } + + return null; + } +} diff --git a/src/Connection.php b/src/Connection.php index e11401e..400bc96 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -42,23 +42,25 @@ class Connection /** @var \Fiber|null */ protected ?Fiber $writer; + protected ?ChunkedSignatureSigner $chunkedSigner; + /** * Create a new connection instance. * * @param array $requestHeaders */ - public function __construct(public string $url, public array $requestHeaders = []) + public function __construct(public string $url, public array $requestHeaders = [], ?ChunkedSignatureSigner $chunkedSigner = null) { - $host = parse_url($url, PHP_URL_HOST); + $host = parse_url($this->url, PHP_URL_HOST); if ($host === false || $host === null) { throw new Exception('[Litebase Client Error]: Invalid URL provided'); } $this->host = $host; - $this->port = parse_url($url, PHP_URL_PORT) ?: 80; + $this->port = parse_url($this->url, PHP_URL_PORT) ?: 80; - $path = parse_url($url, PHP_URL_PATH); + $path = parse_url($this->url, PHP_URL_PATH); if ($path === false || $path === null) { throw new Exception('[Litebase Client Error]: Invalid URL provided'); @@ -71,6 +73,7 @@ public function __construct(public string $url, public array $requestHeaders = [ } $this->queryRequestEncoder = new QueryRequestEncoder; + $this->chunkedSigner = $chunkedSigner; } /** @@ -357,7 +360,7 @@ public function open(): void stream_set_timeout($this->socket, 5); $error = fwrite($this->socket, "POST {$this->path} HTTP/1.1\r\n"); - $error = fwrite($this->socket, implode("\r\n", $this->headers)."\r\n"); + $error = fwrite($this->socket, implode("\r\n", $this->headers) . "\r\n"); $error = fwrite($this->socket, "\r\n"); if ($error === false) { @@ -367,7 +370,7 @@ public function open(): void $this->open = true; $this->messages = [ - pack('C', QueryStreamMessageType::OPEN_CONNECTION->value.pack('V', 0)), + pack('C', QueryStreamMessageType::OPEN_CONNECTION->value . pack('V', 0)), ...$this->messages, ]; @@ -381,7 +384,28 @@ public function send(Query $query): QueryResult { $queryRequest = $this->queryRequestEncoder->encode($query); - $frame = pack('C', QueryStreamMessageType::FRAME->value).pack('V', strlen($queryRequest)).$queryRequest; + // If chunked signer is available, create a signed frame per LQTP protocol + if ($this->chunkedSigner !== null) { + // Frame data: [QueryLength:4][QueryData] + $frameData = pack('V', strlen($queryRequest)) . $queryRequest; + + // Sign the frame data using chunked signature scheme (similar to AWS Sig4) + $chunkSignature = $this->chunkedSigner->signChunk($frameData); + + // Build complete frame with signature per LQTP protocol + // Frame format: [MessageType:1][FrameLength:4][SignatureLength:4][Signature:N][FrameData] + $signatureBytes = $chunkSignature; + $totalLength = 4 + strlen($signatureBytes) + strlen($frameData); + + $frame = pack('C', QueryStreamMessageType::FRAME->value) // Message type (0x04) + . pack('V', $totalLength) // Total length (signature metadata + frame data) + . pack('V', strlen($signatureBytes)) // Signature length + . $signatureBytes // Hex-encoded chunk signature + . $frameData; // Frame data + } else { + // Fallback to unsigned frame format (deprecated) + $frame = pack('C', QueryStreamMessageType::FRAME->value) . pack('V', strlen($queryRequest)) . $queryRequest; + } $this->messages[] = $frame; @@ -410,7 +434,7 @@ public function send(Query $query): QueryResult continue; } catch (Exception $reconnectException) { - throw new Exception('[Litebase Client Error]: Failed to reconnect after connection loss: '.$reconnectException->getMessage()); + throw new Exception('[Litebase Client Error]: Failed to reconnect after connection loss: ' . $reconnectException->getMessage()); } } } @@ -565,7 +589,7 @@ protected function writeMessage(string $message): void $chunkSize = dechex(strlen($message)); $n = $this->socket ? - fwrite($this->socket, $chunkSize."\r\n".$message."\r\n") : + fwrite($this->socket, $chunkSize . "\r\n" . $message . "\r\n") : false; if ($n === false) { diff --git a/src/HttpStreamingTransport.php b/src/HttpStreamingTransport.php index 8390316..0bc99d8 100644 --- a/src/HttpStreamingTransport.php +++ b/src/HttpStreamingTransport.php @@ -12,6 +12,8 @@ class HttpStreamingTransport implements TransportInterface protected Connection $connection; + protected ?ChunkedSignatureSigner $chunkedSigner = null; + /** * Create a new instance of the transport. */ @@ -42,27 +44,40 @@ public function send(Query $query): ?QueryResult : sprintf('http://%s:%d/%s', $this->config->getHost(), $this->config->getPort(), $path); if (! empty($this->config->getUsername()) || ! (empty($this->config->getPassword()))) { - $headers['Authorization'] = 'Basic '.base64_encode($this->config->getUsername().':'.$this->config->getPassword()); + $headers['Authorization'] = 'Basic ' . base64_encode($this->config->getUsername() . ':' . $this->config->getPassword()); } if (! empty($this->config->getAccessToken())) { - $headers['Authorization'] = 'Bearer '.$this->config->getAccessToken(); + $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken(); } if (! empty($this->config->getAccessKeyId())) { + // Use the streaming payload marker for chunked signature validation $token = $this->getToken( accessKeyID: $this->config->getAccessKeyId(), accessKeySecret: $this->config->getAccessKeySecret(), method: 'POST', path: $path, headers: $headers, - data: null, + data: 'STREAMING-LITEBASE-HMAC-SHA256-PAYLOAD', ); $headers['Authorization'] = sprintf('Litebase-HMAC-SHA256 %s', $token); + + // Extract the seed signature from the token for chunk signing + $seedSignature = ChunkedSignatureSigner::extractSignatureFromToken($token); + + if ($seedSignature !== null) { + // Create the chunked signature signer with the seed signature + $this->chunkedSigner = new ChunkedSignatureSigner( + $this->config->getAccessKeySecret(), + $headers['X-Litebase-Date'], + $seedSignature + ); + } } - $this->connection = new Connection($url, $headers); + $this->connection = new Connection($url, $headers, $this->chunkedSigner); } try { diff --git a/src/Middleware/AuthMiddleware.php b/src/Middleware/AuthMiddleware.php index bf2b520..51fe5ec 100644 --- a/src/Middleware/AuthMiddleware.php +++ b/src/Middleware/AuthMiddleware.php @@ -46,16 +46,13 @@ private function signRequest(RequestInterface $request): RequestInterface contentLength: strlen($body) ); - $decodedBody = json_decode($body, true); - $data = is_array($decodedBody) ? $decodedBody : null; - $token = $this->getToken( accessKeyID: $this->config->getAccessKeyId(), accessKeySecret: $this->config->getAccessKeySecret(), method: $request->getMethod(), path: $request->getUri()->getPath(), headers: $headers, - data: $data, + data: $body, ); // Add signed headers to request diff --git a/src/RequestSigner.php b/src/RequestSigner.php index faa6d18..e82a0a1 100644 --- a/src/RequestSigner.php +++ b/src/RequestSigner.php @@ -8,7 +8,6 @@ class RequestSigner * Sign a request and return the authorization token. * * @param array $headers - * @param array $data * @param array $queryParams */ public static function handle( @@ -17,25 +16,30 @@ public static function handle( string $method, string $path, array $headers, - ?array $data, + string $data, array $queryParams = [], ): string { $headers = array_change_key_case($headers); ksort($headers); $headers = array_filter( $headers, - fn ($value, $key) => in_array($key, ['content-type', 'host', 'x-litebase-date']), + fn($value, $key) => in_array($key, ['content-type', 'host', 'x-litebase-date']), ARRAY_FILTER_USE_BOTH ); $queryParams = array_change_key_case($queryParams); ksort($queryParams); - $bodyHash = hash('sha256', (empty($data) ? '' : (json_encode($data, JSON_UNESCAPED_SLASHES) ?: ''))); + // Handle special streaming payload marker + if ($data === 'STREAMING-LITEBASE-HMAC-SHA256-PAYLOAD') { + $bodyHash = hash('sha256', $data); + } else { + $bodyHash = hash('sha256', (empty($data) ? '' : $data)); + } $requestString = implode('', [ $method, - '/'.ltrim($path, '/'), + '/' . ltrim($path, '/'), json_encode($headers, JSON_UNESCAPED_SLASHES), json_encode((empty($queryParams)) ? (object) [] : $queryParams, JSON_UNESCAPED_SLASHES), $bodyHash, diff --git a/src/SignsRequests.php b/src/SignsRequests.php index c5aebbe..f4196f5 100644 --- a/src/SignsRequests.php +++ b/src/SignsRequests.php @@ -8,7 +8,6 @@ trait SignsRequests * Get an authorization token for a request. * * @param array $headers - * @param array $data * @param array $queryParams */ public function getToken( @@ -18,7 +17,7 @@ public function getToken( string $method, string $path, array $headers, - ?array $data, + string $data, array $queryParams = [], ): string { return RequestSigner::handle( diff --git a/tests/ChunkedSignatureSignerTest.php b/tests/ChunkedSignatureSignerTest.php new file mode 100644 index 0000000..e306fe6 --- /dev/null +++ b/tests/ChunkedSignatureSignerTest.php @@ -0,0 +1,185 @@ +toBeInstanceOf(ChunkedSignatureSigner::class); + expect($signer->getPreviousSignature())->toBe($seedSignature); + }); + + test('signChunk', function () { + $accessKeySecret = 'my-secret-key-12345'; + $date = '1699718400'; + $seedSignature = 'initial-seed-signature'; + $chunkData = 'test chunk data'; + + $signer = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + $signature = $signer->signChunk($chunkData); + + // Verify signature is a hex string + expect($signature)->toMatch('/^[a-f0-9]+$/'); + expect(strlen($signature))->toBe(64); // SHA256 produces 64 hex characters + + // Verify the previous signature was updated + expect($signer->getPreviousSignature())->toBe($signature); + }); + + test('signChunkChaining', function () { + + $accessKeySecret = 'my-secret-key-12345'; + $date = '1699718400'; + $seedSignature = 'initial-seed-signature'; + + $signer = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + + // First chunk + $chunk1 = 'first chunk'; + $signature1 = $signer->signChunk($chunk1); + expect($signature1)->toBe($signer->getPreviousSignature()); + + // Second chunk - should use signature1 in its calculation + $chunk2 = 'second chunk'; + $signature2 = $signer->signChunk($chunk2); + expect($signature2)->toBe($signer->getPreviousSignature()); + expect($signature1)->not->toBe($signature2); + + // Third chunk - should use signature2 in its calculation + $chunk3 = 'third chunk'; + $signature3 = $signer->signChunk($chunk3); + expect($signature3)->toBe($signer->getPreviousSignature()); + expect($signature2)->not->toBe($signature3); + }); + + test('signChunkDeterministic', function () { + $accessKeySecret = 'my-secret-key'; + $date = '1699718400'; + $seedSignature = 'seed-signature'; + $chunkData = 'test data'; + + // Create two signers with same parameters + $signer1 = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + $signer2 = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + + $signature1 = $signer1->signChunk($chunkData); + $signature2 = $signer2->signChunk($chunkData); + + // Should produce the same signature + expect($signature1)->toBe($signature2); + }); + + test('signChunkDifferentSecrets', function () { + $secret1 = 'secret-one'; + $secret2 = 'secret-two'; + $date = '1699718400'; + $seedSignature = 'seed'; + $chunkData = 'data'; + + $signer1 = new ChunkedSignatureSigner($secret1, $date, $seedSignature); + $signer2 = new ChunkedSignatureSigner($secret2, $date, $seedSignature); + + $signature1 = $signer1->signChunk($chunkData); + $signature2 = $signer2->signChunk($chunkData); + + // Different secrets should produce different signatures + expect($signature1)->not->toBe($signature2); + }); + + test('signChunkDifferentDates', function () { + $accessKeySecret = 'my-secret'; + $date1 = '1699718400'; + $date2 = '1699718401'; + $seedSignature = 'seed'; + $chunkData = 'data'; + + $signer1 = new ChunkedSignatureSigner($accessKeySecret, $date1, $seedSignature); + $signer2 = new ChunkedSignatureSigner($accessKeySecret, $date2, $seedSignature); + + $signature1 = $signer1->signChunk($chunkData); + $signature2 = $signer2->signChunk($chunkData); + + // Different dates should produce different signatures + expect($signature1)->not->toBe($signature2); + }); + + test('signChunkEmptyData', function () { + $signer = new ChunkedSignatureSigner('secret', '1699718400', 'seed'); + $signature = $signer->signChunk(''); + + // Should handle empty data without errors + expect($signature)->toMatch('/^[a-f0-9]+$/'); + expect(strlen($signature))->toBe(64); + }); + + test('signChunkLargeData', function () { + $signer = new ChunkedSignatureSigner('secret', '1699718400', 'seed'); + $largeData = str_repeat('a', 1024 * 1024); // 1MB + $signature = $signer->signChunk($largeData); + + // Should handle large data without errors + expect($signature)->toMatch('/^[a-f0-9]+$/'); + expect(strlen($signature))->toBe(64); + }); + + test('extractSignatureFromToken', function () { + $token = base64_encode('credential=test-key;signed_headers=content-type,host;signature=abc123def456'); + $signature = ChunkedSignatureSigner::extractSignatureFromToken($token); + + expect($signature)->toBe('abc123def456'); + }); + + test('extractSignatureFromTokenNotFound', function () { + $token = base64_encode('credential=test-key;signed_headers=content-type,host'); + $signature = ChunkedSignatureSigner::extractSignatureFromToken($token); + + expect($signature)->toBeNull(); + }); + + test('extractSignatureFromInvalidToken', function () { + $token = 'not-valid-base64!!!'; + $signature = ChunkedSignatureSigner::extractSignatureFromToken($token); + + expect($signature)->toBeNull(); + }); + + test('getPreviousSignature', function () { + $seedSignature = 'initial-signature'; + $signer = new ChunkedSignatureSigner('secret', '1699718400', $seedSignature); + + // Should start with seed signature + expect($seedSignature)->toBe($signer->getPreviousSignature()); + + // After signing, should update + $newSignature = $signer->signChunk('data'); + expect($newSignature)->toBe($signer->getPreviousSignature()); + }); + + test('signatureMatchesSnapshot', function () { + // This test verifies compatibility with the Go implementation + // Using known test values to ensure cross-platform compatibility + $accessKeySecret = 'test-secret'; + $date = '1699718400'; + $seedSignature = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; // SHA256 of empty string + $chunkData = 'test chunk data'; + + $signer = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + $signature = $signer->signChunk($chunkData); + + // The signature should be deterministic + // Calculate it again with a new signer to verify + $signer2 = new ChunkedSignatureSigner($accessKeySecret, $date, $seedSignature); + $signature2 = $signer2->signChunk($chunkData); + + expect($signature)->toBe($signature2); + }); +}); From 3af5864ec0a592066783ecff77eb5dd773951817 Mon Sep 17 00:00:00 2001 From: Thiery Laverdure Date: Thu, 13 Nov 2025 09:02:30 -0500 Subject: [PATCH 2/3] Enforce required config and improve streaming transport Adds validation for required database and branch names in LitebaseClient and HttpStreamingTransport. Refactors credential handling to use nullable types and ensures access key presence for streaming. Fixes binary encoding in QueryRequestEncoder and improves error messages. Adds integration tests for LitebaseClient LQTP support. --- src/ChunkedSignatureSigner.php | 13 +-- src/Configuration.php | 10 +- src/Connection.php | 1 + src/HttpStreamingTransport.php | 56 ++++++----- src/LitebaseClient.php | 12 ++- src/QueryRequestEncoder.php | 13 ++- tests/Integration/ApiClientTest.php | 2 +- tests/Integration/LitebaseClientTest.php | 122 +++++++++++++++++++++++ 8 files changed, 177 insertions(+), 52 deletions(-) create mode 100644 tests/Integration/LitebaseClientTest.php diff --git a/src/ChunkedSignatureSigner.php b/src/ChunkedSignatureSigner.php index 5c823b7..0352177 100644 --- a/src/ChunkedSignatureSigner.php +++ b/src/ChunkedSignatureSigner.php @@ -8,7 +8,7 @@ */ class ChunkedSignatureSigner { - protected string $accessKeySecret; + protected ?string $accessKeySecret; protected string $date; @@ -16,12 +16,8 @@ class ChunkedSignatureSigner /** * Create a new ChunkedSignatureSigner instance. - * - * @param string $accessKeySecret The access key secret for signing - * @param string $date The date timestamp used in the initial request - * @param string $seedSignature The signature from the initial HTTP request */ - public function __construct(string $accessKeySecret, string $date, string $seedSignature) + public function __construct(?string $accessKeySecret, string $date, string $seedSignature) { $this->accessKeySecret = $accessKeySecret; $this->date = $date; @@ -40,9 +36,6 @@ public function __construct(string $accessKeySecret, string $date, string $seedS * 4. Sign: signature = HMAC-SHA256(serviceKey, stringToSign) * * The signature chains ensure chunks are sent in the correct order and prevents tampering. - * - * @param string $chunkData The raw chunk data to sign - * @return string The hex-encoded signature */ public function signChunk(string $chunkData): string { @@ -54,7 +47,7 @@ public function signChunk(string $chunkData): string $stringToSign = $this->previousSignature . $chunkHash; // Create the signing key chain (same as in request signature validation) - $dateKey = hash_hmac('sha256', $this->date, $this->accessKeySecret); + $dateKey = hash_hmac('sha256', $this->date, $this->accessKeySecret ?? ''); $serviceKey = hash_hmac('sha256', 'litebase_request', $dateKey); // Sign the chunk diff --git a/src/Configuration.php b/src/Configuration.php index a780141..474cf72 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -9,9 +9,9 @@ */ class Configuration extends BaseConfiguration { - protected string $accessKeyId = ''; + protected ?string $accessKeyId; - protected string $accessKeySecret = ''; + protected ?string $accessKeySecret; protected ?string $database = ''; @@ -24,7 +24,7 @@ class Configuration extends BaseConfiguration */ public function getAccessKeyId(): string { - return $this->accessKeyId; + return $this->accessKeyId ?? ''; } /** @@ -32,7 +32,7 @@ public function getAccessKeyId(): string */ public function getAccessKeySecret(): string { - return $this->accessKeySecret; + return $this->accessKeySecret ?? ''; } /** @@ -70,7 +70,7 @@ public function getPort(): ?string /** * Set access key credentials for HMAC-SHA256 authentication */ - public function setAccessKey(string $accessKeyId, string $accessKeySecret): self + public function setAccessKey(?string $accessKeyId, ?string $accessKeySecret): self { $this->accessKeyId = $accessKeyId; $this->accessKeySecret = $accessKeySecret; diff --git a/src/Connection.php b/src/Connection.php index 400bc96..dc5edfd 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -56,6 +56,7 @@ public function __construct(public string $url, public array $requestHeaders = [ if ($host === false || $host === null) { throw new Exception('[Litebase Client Error]: Invalid URL provided'); } + $this->host = $host; $this->port = parse_url($this->url, PHP_URL_PORT) ?: 80; diff --git a/src/HttpStreamingTransport.php b/src/HttpStreamingTransport.php index 0bc99d8..4fb239e 100644 --- a/src/HttpStreamingTransport.php +++ b/src/HttpStreamingTransport.php @@ -23,6 +23,12 @@ public function __construct( public function send(Query $query): ?QueryResult { + if (empty($this->config->getDatabase()) || empty($this->config->getBranch())) { + throw new LitebaseConnectionException( + message: '[Litebase Client Error] Database and Branch names must be set for the streaming transport', + ); + } + $path = sprintf( 'v1/databases/%s/branches/%s/query/stream', $this->config->getDatabase(), @@ -43,38 +49,34 @@ public function send(Query $query): ?QueryResult ? sprintf('https://%s/%s', $this->config->getHost(), $path) : sprintf('http://%s:%d/%s', $this->config->getHost(), $this->config->getPort(), $path); - if (! empty($this->config->getUsername()) || ! (empty($this->config->getPassword()))) { - $headers['Authorization'] = 'Basic ' . base64_encode($this->config->getUsername() . ':' . $this->config->getPassword()); - } - - if (! empty($this->config->getAccessToken())) { - $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken(); + if (empty($this->config->getAccessKeyId())) { + throw new LitebaseConnectionException( + message: '[Litebase Client Error] An Access key is required for the streaming transport', + ); } - if (! empty($this->config->getAccessKeyId())) { - // Use the streaming payload marker for chunked signature validation - $token = $this->getToken( - accessKeyID: $this->config->getAccessKeyId(), - accessKeySecret: $this->config->getAccessKeySecret(), - method: 'POST', - path: $path, - headers: $headers, - data: 'STREAMING-LITEBASE-HMAC-SHA256-PAYLOAD', - ); + // Use the streaming payload marker for chunked signature validation + $token = $this->getToken( + accessKeyID: $this->config->getAccessKeyId(), + accessKeySecret: $this->config->getAccessKeySecret(), + method: 'POST', + path: $path, + headers: $headers, + data: 'STREAMING-LITEBASE-HMAC-SHA256-PAYLOAD', + ); - $headers['Authorization'] = sprintf('Litebase-HMAC-SHA256 %s', $token); + $headers['Authorization'] = sprintf('Litebase-HMAC-SHA256 %s', $token); - // Extract the seed signature from the token for chunk signing - $seedSignature = ChunkedSignatureSigner::extractSignatureFromToken($token); + // Extract the seed signature from the token for chunk signing + $seedSignature = ChunkedSignatureSigner::extractSignatureFromToken($token); - if ($seedSignature !== null) { - // Create the chunked signature signer with the seed signature - $this->chunkedSigner = new ChunkedSignatureSigner( - $this->config->getAccessKeySecret(), - $headers['X-Litebase-Date'], - $seedSignature - ); - } + if ($seedSignature !== null) { + // Create the chunked signature signer with the seed signature + $this->chunkedSigner = new ChunkedSignatureSigner( + $this->config->getAccessKeySecret(), + $headers['X-Litebase-Date'], + $seedSignature + ); } $this->connection = new Connection($url, $headers, $this->chunkedSigner); diff --git a/src/LitebaseClient.php b/src/LitebaseClient.php index ebb7f7a..f995fb7 100644 --- a/src/LitebaseClient.php +++ b/src/LitebaseClient.php @@ -47,7 +47,15 @@ class LitebaseClient */ public function __construct( protected Configuration $configuration, - ) {} + ) { + if (!$this->configuration->getDatabase()) { + throw new Exception('[Litebase Client Error] Database name must be set in the configuration.'); + } + + if (!$this->configuration->getBranch()) { + throw new Exception('[Litebase Client Error] Branch name must be set in the configuration.'); + } + } /** * Begin a transaction. @@ -220,7 +228,7 @@ public function withTransport(string $transportType): LitebaseClient $this->transport = new HttpStreamingTransport($this->configuration); break; default: - throw new Exception('Invalid transport type: '.$transportType); + throw new Exception('Invalid transport type: ' . $transportType); } return $this; diff --git a/src/QueryRequestEncoder.php b/src/QueryRequestEncoder.php index 3969073..2807fcc 100644 --- a/src/QueryRequestEncoder.php +++ b/src/QueryRequestEncoder.php @@ -9,7 +9,7 @@ public static function encode(Query $query): string $binaryData = ''; $id = $query->id; $idLength = pack('V', strlen($id)); - $binaryData .= $idLength.$id; + $binaryData .= $idLength . $id; $transactionIdLength = pack('V', strlen($query->transactionId ?? '')); $binaryData .= $transactionIdLength; @@ -20,7 +20,7 @@ public static function encode(Query $query): string $statement = $query->statement; $statementLength = pack('V', strlen($statement)); - $binaryData .= $statementLength.$statement; + $binaryData .= $statementLength . $statement; $parametersBinary = ''; @@ -72,15 +72,14 @@ public static function encode(Query $query): string $parameterType = pack('C', $parameterType); // Parameter value with length prefix (4 bytes little-endian + value) - $parameterValueWithLength = pack('V', $parameterValueLength).$parameterValue; + $parameterValueWithLength = pack('V', $parameterValueLength) . $parameterValue; - $parametersBinary .= $parameterType.$parameterValueWithLength; + $parametersBinary .= $parameterType . $parameterValueWithLength; } $parametersBinaryLength = pack('V', strlen($parametersBinary)); - $binaryData .= $parametersBinaryLength.$parametersBinary; - $queryBinary = pack('V', strlen($binaryData)).$binaryData; + $binaryData .= $parametersBinaryLength . $parametersBinary; - return $queryBinary; + return $binaryData; } } diff --git a/tests/Integration/ApiClientTest.php b/tests/Integration/ApiClientTest.php index 0452ea4..adc918e 100644 --- a/tests/Integration/ApiClientTest.php +++ b/tests/Integration/ApiClientTest.php @@ -23,7 +23,7 @@ try { $response = $client->clusterStatus()->listClusterStatuses(); } catch (\Exception $e) { - throw new \RuntimeException('Failed to connect to Litebase server for integration tests: '.$e->getMessage()); + throw new \RuntimeException('Failed to connect to Litebase server for integration tests: ' . $e->getMessage()); } if ($response->getStatus() !== 'success') { diff --git a/tests/Integration/LitebaseClientTest.php b/tests/Integration/LitebaseClientTest.php new file mode 100644 index 0000000..d2060cc --- /dev/null +++ b/tests/Integration/LitebaseClientTest.php @@ -0,0 +1,122 @@ +setHost('127.0.0.1') + ->setPort('8888') + ->setUsername('root') + ->setPassword('password'); + +$client = new ApiClient($configuration); + +beforeAll(function () use ($client) { + LitebaseContainer::start(); + + try { + $response = $client->clusterStatus()->listClusterStatuses(); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to connect to Litebase server for integration tests: ' . $e->getMessage()); + } + + if ($response->getStatus() !== 'success') { + throw new \RuntimeException('Failed to connect to Litebase server for integration tests.'); + } +}); + +afterAll(function () { + LitebaseContainer::stop(); +}); + +describe('LitebaseClient', function () use ($client) { + test('LQTP support', function () use ($client) { + $databaseResponse = $client->database() + ->createDatabase(new \Litebase\OpenAPI\Model\DatabaseStoreRequest([ + 'name' => 'test', + ])); + + if (!$databaseResponse instanceof \Litebase\OpenAPI\Model\CreateDatabase200Response) { + throw new \RuntimeException('Invalid response when creating database for integration tests.'); + } + + $response = $client->accessKey()->createAccessKey( + new \Litebase\OpenAPI\Model\AccessKeyStoreRequest([ + 'description' => 'test-key', + 'statements' => [ + new Statement([ + 'effect' => StatementEffect::ALLOW, + 'actions' => [Privilege::STAR], + 'resource' => '*', + ]), + ], + ]) + ); + + if (!$response instanceof \Litebase\OpenAPI\Model\CreateAccessKey201Response) { + throw new \RuntimeException('Invalid response when creating access key for integration tests.'); + } + + $accessKeyId = $response->getData()->getAccessKeyId(); + $accessKeySecret = $response->getData()->getAccessKeySecret(); + + $configuration = new Configuration; + + $configuration + ->setHost('127.0.0.1') + ->setPort('8888') + ->setAccessKey($accessKeyId, $accessKeySecret) + ->setDatabase(sprintf( + '%s/%s', + $databaseResponse->getData()->getDatabaseName(), + $databaseResponse->getData()->getBranchName() + )); + + $litebaseClient = new LitebaseClient($configuration); + + $litebaseClient = $litebaseClient->withTransport('http'); + + // Create a table + $result = $litebaseClient->exec([ + 'statement' => 'CREATE TABLE IF NOT EXISTS lqtp_test (id INTEGER PRIMARY KEY AUTOINCREMENT, test_value INTEGER)' + ]); + + expect($result?->errorMessage)->toBeNull(); + + // Insert a value + $result = $litebaseClient->exec([ + 'statement' => 'INSERT INTO lqtp_test (test_value) VALUES (?)', + 'parameters' => [ + [ + 'type' => 'INTEGER', + 'value' => 42 + ] + ], + ]); + + expect($result?->changes)->toBe(1); + + // Query the value + $queryResult = $litebaseClient->exec([ + 'statement' => 'SELECT test_value FROM lqtp_test WHERE id = ?', + 'parameters' => [ + [ + 'type' => 'INTEGER', + 'value' => 1 + ] + ], + ]); + + expect($queryResult?->rows[0][0])->toBe(42); + }); +}); From 341785a1e03db5c6da4311dc66b0c1be6ecb82f8 Mon Sep 17 00:00:00 2001 From: Thiery Laverdure Date: Thu, 13 Nov 2025 09:13:14 -0500 Subject: [PATCH 3/3] Update LitebaseClient.php --- src/LitebaseClient.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/LitebaseClient.php b/src/LitebaseClient.php index f995fb7..23260b4 100644 --- a/src/LitebaseClient.php +++ b/src/LitebaseClient.php @@ -47,15 +47,7 @@ class LitebaseClient */ public function __construct( protected Configuration $configuration, - ) { - if (!$this->configuration->getDatabase()) { - throw new Exception('[Litebase Client Error] Database name must be set in the configuration.'); - } - - if (!$this->configuration->getBranch()) { - throw new Exception('[Litebase Client Error] Branch name must be set in the configuration.'); - } - } + ) {} /** * Begin a transaction.