Skip to content
Merged
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"ext-curl": "*",
"ext-openssl": "*",
"ext-sodium": "*",
"testcontainers/testcontainers": "1.0.8"
"testcontainers/testcontainers": "^1.0.8"
},
"require-dev": {
"phpstan/phpstan": "2.1.51",
Expand Down Expand Up @@ -60,6 +60,7 @@
"allow-plugins": {
"php-http/discovery": false
},
"bump-after-update": true,
"sort-packages": true
}
}
157 changes: 78 additions & 79 deletions composer.lock

Large diffs are not rendered by default.

150 changes: 47 additions & 103 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use RuntimeException;
use Thenativeweb\Eventsourcingdb\Stream\HttpClient;
use Thenativeweb\Eventsourcingdb\Stream\NdJson;
use Thenativeweb\Eventsourcingdb\Stream\Response;

final readonly class Client
{
Expand All @@ -30,17 +31,9 @@ public function abortIn(float $seconds): void
public function ping(): void
{
$response = $this->httpClient->get('/api/v1/ping');
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();

if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to ping, got HTTP status code '%d', expected '200'",
$status
));
}
$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to ping');

try {
$data = $response->getStream()->getJsonData();
Expand All @@ -63,17 +56,9 @@ public function verifyApiToken(): void
'/api/v1/verify-api-token',
$this->apiToken,
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();

if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to verify API token, got HTTP status code '%d', expected '200'",
$status
));
}
$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to verify API token');

try {
$data = $response->getStream()->getJsonData();
Expand Down Expand Up @@ -104,17 +89,9 @@ public function writeEvents(array $events, array $preconditions = []): array
$this->apiToken,
$requestBody,
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();

if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to write events, got HTTP status code '%d', expected '200'",
$status
));
}
$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to write events');

try {
$data = $response->getStream()->getJsonData();
Expand Down Expand Up @@ -159,17 +136,9 @@ public function readEvents(string $subject, ReadEventsOptions $readEventsOptions
'options' => $readEventsOptions,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();

if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to read events, got HTTP status code '%d', expected '200'",
$status
));
}
$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to read events');

foreach (NdJson::readStream($response->getStream()) as $eventLine) {
switch ($eventLine->type) {
Expand Down Expand Up @@ -210,17 +179,9 @@ public function runEventQlQuery(string $query): iterable
'query' => $query,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();

if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to run EventQL query, got HTTP status code '%d', expected '200'",
$status
));
}
$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to run EventQL query');

foreach (NdJson::readStream($response->getStream()) as $eventLine) {
switch ($eventLine->type) {
Expand Down Expand Up @@ -249,16 +210,9 @@ public function observeEvents(string $subject, ObserveEventsOptions $observeEven
'options' => $observeEventsOptions,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to observe events, got HTTP status code '%d', expected '200'",
$status
));
}

$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to observe events');

foreach (NdJson::readStream($response->getStream()) as $eventLine) {
switch ($eventLine->type) {
Expand Down Expand Up @@ -302,16 +256,9 @@ public function registerEventSchema(string $eventType, array $schema): void
'schema' => $schema,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to register event schema, got HTTP status code '%d', expected '200'",
$status
));
}

$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to register event schema');
}

public function readSubjects(string $baseSubject): iterable
Expand All @@ -323,16 +270,9 @@ public function readSubjects(string $baseSubject): iterable
'baseSubject' => $baseSubject,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to read subjects, got HTTP status code '%d', expected '200'",
$status
));
}

$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to read subjects');

foreach (NdJson::readStream($response->getStream()) as $eventLine) {
switch ($eventLine->type) {
Expand All @@ -357,16 +297,9 @@ public function readEventTypes(): iterable
'/api/v1/read-event-types',
$this->apiToken,
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to read event types, got HTTP status code '%d', expected '200'",
$status
));
}

$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to read event types');

foreach (NdJson::readStream($response->getStream()) as $eventLine) {
switch ($eventLine->type) {
Expand Down Expand Up @@ -398,16 +331,9 @@ public function readEventType(string $eventType): EventType
'eventType' => $eventType,
],
);
if (!$this->isValidServerHeader($response)) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(sprintf(
"Failed to read event type, got HTTP status code '%d', expected '200'",
$status
));
}

$this->throwIfNotValidServerHeader($response);
$this->throwIfNotSuccessStatusCode($response, 'Failed to read event type');

try {
$data = $response->getStream()->getJsonData();
Expand All @@ -426,13 +352,31 @@ public function readEventType(string $eventType): EventType
);
}

private function isValidServerHeader(\Thenativeweb\Eventsourcingdb\Stream\Response $response): bool
private function throwIfNotValidServerHeader(Response $response): void
{
$serverHeader = $response->getHeader('Server');

if ($serverHeader === []) {
return false;
throw new RuntimeException('Server Header is empty.');
}

if (!str_starts_with($serverHeader[0], 'EventSourcingDB/')) {
throw new RuntimeException('Server must be EventSourcingDB.');
}
}

private function throwIfNotSuccessStatusCode(Response $response, string $scope): void
{
$status = $response->getStatusCode();
if ($status !== 200) {
throw new RuntimeException(
message: sprintf(
'%s, %s',
$scope,
$response->getStream()->getContents(),
),
code: $status,
);
}
return str_starts_with($serverHeader[0], 'EventSourcingDB/');
}
}
8 changes: 6 additions & 2 deletions src/CloudEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,12 @@ public function verifySignature(string $verificationKey): void
throw new RuntimeException('Failed to decode signature from hex.');
}

if ($signatureBytes === '') {
throw new RuntimeException('Signature must not be empty.');
if (strlen($signatureBytes) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new RuntimeException(sprintf(
'Signature has an invalid length: expected %d bytes, got %d bytes.',
SODIUM_CRYPTO_SIGN_BYTES,
strlen($signatureBytes),
));
}

$isSignatureValid = sodium_crypto_sign_verify_detached($signatureBytes, $this->hash, $verificationKey);
Expand Down
8 changes: 4 additions & 4 deletions src/SigningKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

final class SigningKey
{
public string $privateKeyPem;
public string $publicKeyPem;
public Ed25519 $ed25519;
public readonly string $privateKeyPem;
public readonly string $publicKeyPem;
public readonly Ed25519 $ed25519;

public function __construct()
{
Expand All @@ -25,7 +25,7 @@ public function __construct()
$secretKey = sodium_crypto_sign_secretkey($keypair);

$privateKey = substr($secretKey, 0, 32);
$publicKey = substr($secretKey, 32, 32);
$publicKey = sodium_crypto_sign_publickey($keypair);

$this->privateKeyPem = $this->generatePem($privateKey, 'PRIVATE KEY');
$this->publicKeyPem = $this->generatePem($publicKey, 'PUBLIC KEY');
Expand Down
82 changes: 82 additions & 0 deletions tests/CloudEventSignatureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,88 @@ protected function tearDown(): void
parent::tearDown();
}

public function testThrowsAnErrorIfVerificationKeyIsEmpty(): void
{
$imageVersion = getImageVersionFromDockerfile();
$container = (new Container())
->withImageTag($imageVersion)
->withSigningKey();
$container->start();
$this->container = $container;

$client = $container->getClient();

$writtenEvents = $client->writeEvents([
new EventCandidate(
source: 'https://www.eventsourcingdb.io',
subject: '/test',
type: 'io.eventsourcingdb.test',
data: [
'value' => 23,
],
),
]);

$this->assertCount(1, $writtenEvents);
$this->assertNotNull($writtenEvents[0]->signature);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Verification key must not be empty.');

$writtenEvents[0]->verifySignature('');
}

public function testThrowsAnErrorIfSignatureHasInvalidLength(): void
{
$imageVersion = getImageVersionFromDockerfile();
$container = (new Container())
->withImageTag($imageVersion)
->withSigningKey();
$container->start();
$this->container = $container;

$client = $container->getClient();

$writtenEvents = $client->writeEvents([
new EventCandidate(
source: 'https://www.eventsourcingdb.io',
subject: '/test',
type: 'io.eventsourcingdb.test',
data: [
'value' => 23,
],
),
]);

$this->assertCount(1, $writtenEvents);

$writtenEvent = $writtenEvents[0];

$this->assertNotNull($writtenEvent->signature);

$tamperedCloudEvent = new CloudEvent(
specVersion: $writtenEvent->specVersion,
id: $writtenEvent->id,
time: $writtenEvent->time,
timeFromServer: $this->getPropertyValue($writtenEvent, 'timeFromServer'),
source: $writtenEvent->source,
subject: $writtenEvent->subject,
type: $writtenEvent->type,
dataContentType: $writtenEvent->dataContentType,
data: $writtenEvent->data,
hash: $writtenEvent->hash,
predecessorHash: $writtenEvent->predecessorHash,
traceParent: $writtenEvent->traceParent,
traceState: $writtenEvent->traceState,
signature: 'esdb:signature:v1:deadbeef',
);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Signature has an invalid length: expected 64 bytes, got 4 bytes.');

$tamperedCloudEvent->verifySignature($container->getVerificationKey());
}

public function testThrowsAnErrorIfTheSignatureIsNull(): void
{
$imageVersion = getImageVersionFromDockerfile();
Expand Down
2 changes: 2 additions & 0 deletions tests/CloudEventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public function testThrowsAnErrorIfTheEventHashIsInvalid(): void
);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Failed to verify hash');

$tamperedCloudEvent->verifyHash();
}
}
Loading