Skip to content

Commit 76b7b5f

Browse files
authored
feat: Add event signing verification. (#147)
* feat: Add event signing verification. * Fix tests. * Fix tests.
1 parent e013156 commit 76b7b5f

File tree

5 files changed

+289
-11
lines changed

5 files changed

+289
-11
lines changed

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -511,10 +511,26 @@ event_type = await client.read_event_type("io.eventsourcingdb.library.book-acqui
511511
To verify the integrity of an event, call the `verify_hash` function on the event instance. This recomputes the event's hash locally and compares it to the hash stored in the event. If the hashes differ, the function raises an error:
512512

513513
```python
514-
event.verify_hash();
514+
event.verify_hash()
515515
```
516516

517-
*Note that this only verifies the hash. If you also want to verify the signature, you can skip this step and call `verifySignature` directly, which performs a hash verification internally.*
517+
*Note that this only verifies the hash. If you also want to verify the signature, you can skip this step and call `verify_signature` directly, which performs a hash verification internally.*
518+
519+
### Verifying an Event's Signature
520+
521+
To verify the authenticity of an event, call the `verify_signature` function on the event instance. This requires the public key that matches the private key used for signing on the server.
522+
523+
The function first verifies the event's hash, and then checks the signature. If any verification step fails, it raises an error:
524+
525+
```python
526+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
527+
528+
# ...
529+
530+
verification_key = # public key as Ed25519PublicKey
531+
532+
event.verify_signature(verification_key)
533+
```
518534

519535
### Using Testcontainers
520536

@@ -560,6 +576,24 @@ container = (
560576
)
561577
```
562578

579+
If you want to sign events, call the `with_signing_key` function. This generates a new signing and verification key pair inside the container:
580+
581+
```python
582+
container = (
583+
Container()
584+
.with_signing_key()
585+
)
586+
```
587+
588+
You can retrieve the private key (for signing) and the public key (for verifying signatures) once the container has been started:
589+
590+
```python
591+
signing_key = container.get_signing_key()
592+
verification_key = container.get_verification_key()
593+
```
594+
595+
The `signing_key` can be used when configuring the container to sign outgoing events. The `verification_key` can be passed to `verify_signature` when verifying events read from the database.
596+
563597
#### Configuring the Client Manually
564598

565599
In case you need to set up the client yourself, use the following functions to get details on the container:

eventsourcingdb/container.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import io
12
import logging
3+
import tarfile
24
import time
35
from http import HTTPStatus
46

57
import docker
68
import requests
79
from docker import DockerClient, errors
10+
from cryptography.hazmat.primitives.asymmetric import ed25519
11+
from cryptography.hazmat.primitives import serialization
812

913
from .client import Client
1014

@@ -21,6 +25,7 @@ def __init__(
2125
self._docker_client: DockerClient = docker.from_env()
2226
self._mapped_port: int | None = None
2327
self._host = "localhost"
28+
self._signing_key: ed25519.Ed25519PrivateKey | None = None
2429

2530
def _cleanup_existing_containers(self) -> None:
2631
try:
@@ -44,20 +49,57 @@ def _cleanup_existing_containers(self) -> None:
4449

4550
def _create_container(self) -> None:
4651
port_bindings = {f"{self._internal_port}/tcp": None}
52+
53+
command = [
54+
"run",
55+
"--api-token",
56+
self._api_token,
57+
"--data-directory-temporary",
58+
"--http-enabled",
59+
"--https-enabled=false",
60+
]
61+
62+
if self._signing_key is not None:
63+
target_path = "/etc/esdb/signing-key.pem"
64+
command.extend(["--signing-key-file", target_path])
65+
4766
self._container = self._docker_client.containers.run(
4867
f"{self._image_name}:{self._image_tag}",
49-
command=[
50-
"run",
51-
"--api-token",
52-
self._api_token,
53-
"--data-directory-temporary",
54-
"--http-enabled",
55-
"--https-enabled=false",
56-
],
68+
command=command,
5769
ports=port_bindings, # type: ignore
5870
detach=True,
5971
) # type: ignore
6072

73+
# Copy signing key into container if needed
74+
if self._signing_key is not None:
75+
signing_key_bytes = self._signing_key.private_bytes(
76+
encoding=serialization.Encoding.PEM,
77+
format=serialization.PrivateFormat.PKCS8,
78+
encryption_algorithm=serialization.NoEncryption()
79+
)
80+
81+
# Create tar archive with both the directory structure and the key file
82+
tar_stream = io.BytesIO()
83+
tar = tarfile.TarFile(fileobj=tar_stream, mode='w')
84+
85+
# Add directory entry
86+
dir_info = tarfile.TarInfo(name='esdb')
87+
dir_info.type = tarfile.DIRTYPE
88+
dir_info.mode = 0o755
89+
tar.addfile(dir_info)
90+
91+
# Add the key file
92+
file_info = tarfile.TarInfo(name='esdb/signing-key.pem')
93+
file_info.size = len(signing_key_bytes)
94+
file_info.mode = 0o644
95+
tar.addfile(file_info, io.BytesIO(signing_key_bytes))
96+
97+
tar.close()
98+
tar_stream.seek(0)
99+
100+
# Put the archive into /etc which should exist
101+
self._container.put_archive('/etc', tar_stream)
102+
61103
def _extract_port_from_container_info(self, container_info) -> int | None:
62104
port = None
63105
valid_mapping = True
@@ -233,3 +275,17 @@ def with_image_tag(self, tag: str) -> "Container":
233275
def with_port(self, port: int) -> "Container":
234276
self._internal_port = port
235277
return self
278+
279+
def with_signing_key(self) -> "Container":
280+
self._signing_key = ed25519.Ed25519PrivateKey.generate()
281+
return self
282+
283+
def get_signing_key(self) -> ed25519.Ed25519PrivateKey:
284+
if self._signing_key is None:
285+
raise RuntimeError("Signing key not set.")
286+
return self._signing_key
287+
288+
def get_verification_key(self) -> ed25519.Ed25519PublicKey:
289+
if self._signing_key is None:
290+
raise RuntimeError("Signing key not set.")
291+
return self._signing_key.public_key()

eventsourcingdb/event/event.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from hashlib import sha256
55
from typing import Any, TypeVar
66

7+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
8+
79
from ..errors.internal_error import InternalError
810
from ..errors.validation_error import ValidationError
911

@@ -25,6 +27,7 @@ class Event:
2527
hash: str
2628
trace_parent: str | None = None
2729
trace_state: str | None = None
30+
signature: str | None = None
2831

2932
@staticmethod
3033
def parse(unknown_object: dict) -> "Event":
@@ -78,6 +81,10 @@ def parse(unknown_object: dict) -> "Event":
7881
if trace_state is not None and not isinstance(trace_state, str):
7982
raise ValidationError(f"Failed to parse trace_state '{trace_state}' to string.")
8083

84+
signature = unknown_object.get("signature")
85+
if signature is not None and not isinstance(signature, str):
86+
raise ValidationError(f"Failed to parse signature '{signature}' to string.")
87+
8188
data = unknown_object.get("data")
8289
if not isinstance(data, dict):
8390
raise ValidationError(f"Failed to parse data '{data}' to object.")
@@ -95,6 +102,7 @@ def parse(unknown_object: dict) -> "Event":
95102
hash=hash,
96103
trace_parent=trace_parent,
97104
trace_state=trace_state,
105+
signature=signature,
98106
)
99107
event._time_from_server = time_from_server
100108

@@ -130,6 +138,30 @@ def verify_hash(self) -> None:
130138
if final_hash_hex != self.hash:
131139
raise ValidationError("Failed to verify hash.")
132140

141+
def verify_signature(self, verification_key: Ed25519PublicKey) -> None:
142+
if self.signature is None:
143+
raise ValidationError("Signature must not be none.")
144+
145+
self.verify_hash()
146+
147+
signature_prefix = "esdb:signature:v1:"
148+
149+
if not self.signature.startswith(signature_prefix):
150+
raise ValidationError(f"Signature must start with '{signature_prefix}'.")
151+
152+
signature_hex = self.signature[len(signature_prefix):]
153+
try:
154+
signature_bytes = bytes.fromhex(signature_hex)
155+
except ValueError as error:
156+
raise ValidationError("Failed to decode signature.") from error
157+
158+
hash_bytes = self.hash.encode("utf-8")
159+
160+
try:
161+
verification_key.verify(signature_bytes, hash_bytes)
162+
except Exception as error:
163+
raise ValidationError("Signature verification failed.") from error
164+
133165
def to_json(self) -> dict[str, Any]:
134166
json = {
135167
"specversion": self.spec_version,
@@ -148,6 +180,8 @@ def to_json(self) -> dict[str, Any]:
148180
json["traceparent"] = self.trace_parent
149181
if self.trace_state is not None:
150182
json["tracestate"] = self.trace_state
183+
if self.signature is not None:
184+
json["signature"] = self.signature
151185
json["data"] = self.data
152186

153187
return json

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ readme = "README.md"
88
license = "MIT"
99
dependencies = [
1010
"aiohttp==3.13.0",
11+
"cryptography==43.0.0",
1112
"testcontainers==4.13.2",
1213
]
1314

@@ -29,7 +30,7 @@ build-backend = "hatchling.build"
2930

3031
[tool.bandit]
3132
exclude_dirs = ["tests", ".venv"]
32-
skips = ["B101"]
33+
skips = ["B101"]
3334

3435
[tool.hatch.build.targets.wheel]
3536
packages = ["eventsourcingdb"]

0 commit comments

Comments
 (0)