Skip to content

Commit 8922a24

Browse files
authored
feat: Add verify_hash function for events. (#127)
* feat: Add verify_hash function for events. * Add missing init file. * Use original time string from server. * Use ordered dict instead of dict. * Fix import. * Add some debug statements. * Add more debug statements. * Add more debug statements. * Fix json dumps. * Fix indentation. * Add failing test for verify_hash.g * Add missing import. * Add documentation. * Fix import. * Fix hashing.
1 parent e8da565 commit 8922a24

File tree

5 files changed

+117
-12
lines changed

5 files changed

+117
-12
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,16 @@ To list a specific event type, call the `read_event_type` function with the even
506506
event_type = await client.read_event_type("io.eventsourcingdb.library.book-acquired")
507507
```
508508

509+
### Verifying an Event's Hash
510+
511+
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:
512+
513+
```python
514+
event.verify_hash();
515+
```
516+
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.*
518+
509519
### Using Testcontainers
510520

511521
Import the `Container` class, create an instance, call the `start` function to run a test container, get a client, run your test code, and finally call the `stop` function to stop the test container:

eventsourcingdb/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import OrderedDict
12
from collections.abc import AsyncGenerator
23

34
from types import TracebackType
@@ -124,7 +125,7 @@ async def write_events(
124125

125126
response_data = await response.body.read()
126127
response_data = bytes.decode(response_data, encoding='utf-8')
127-
response_data = json.loads(response_data)
128+
response_data = json.loads(response_data, object_pairs_hook=OrderedDict)
128129

129130
if not isinstance(response_data, list):
130131
raise ServerError(

eventsourcingdb/event/event.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, field
22
from datetime import datetime
3+
import json
4+
from hashlib import sha256
35
from typing import Any, TypeVar
46

57
from ..errors.internal_error import InternalError
@@ -17,6 +19,7 @@ class Event:
1719
spec_version: str
1820
event_id: str
1921
time: datetime
22+
_time_from_server: str = field(init=False, repr=False)
2023
data_content_type: str
2124
predecessor_hash: str
2225
hash: str
@@ -45,10 +48,10 @@ def parse(unknown_object: dict) -> "Event":
4548
if not isinstance(event_id, str):
4649
raise ValidationError(f"Failed to parse event_id '{event_id}' to string.")
4750

48-
time_str = unknown_object.get("time")
49-
if not isinstance(time_str, str):
50-
raise ValidationError(f"Failed to parse time '{time_str}' to string.")
51-
time = Event.__parse_time(time_str)
51+
time_from_server = unknown_object.get("time")
52+
if not isinstance(time_from_server, str):
53+
raise ValidationError(f"Failed to parse time '{time_from_server}' to string.")
54+
time = Event.__parse_time(time_from_server)
5255

5356
data_content_type = unknown_object.get("datacontenttype")
5457
if not isinstance(data_content_type, str):
@@ -79,7 +82,7 @@ def parse(unknown_object: dict) -> "Event":
7982
if not isinstance(data, dict):
8083
raise ValidationError(f"Failed to parse data '{data}' to object.")
8184

82-
return Event(
85+
event = Event(
8386
data=data,
8487
source=source,
8588
subject=subject,
@@ -93,6 +96,39 @@ def parse(unknown_object: dict) -> "Event":
9396
trace_parent=trace_parent,
9497
trace_state=trace_state,
9598
)
99+
event._time_from_server = time_from_server
100+
101+
return event
102+
103+
def verify_hash(self) -> None:
104+
metadata = "|".join([
105+
self.spec_version,
106+
self.event_id,
107+
self.predecessor_hash,
108+
self._time_from_server,
109+
self.source,
110+
self.subject,
111+
self.type,
112+
self.data_content_type,
113+
])
114+
115+
metadata_bytes = metadata.encode("utf-8")
116+
data_bytes = json.dumps(
117+
self.data,
118+
separators=(',', ':'),
119+
indent=None,
120+
).encode("utf-8")
121+
122+
metadata_hash = sha256(metadata_bytes).hexdigest()
123+
data_hash = sha256(data_bytes).hexdigest()
124+
125+
final_hash = sha256()
126+
final_hash.update(metadata_hash.encode("utf-8"))
127+
final_hash.update(data_hash.encode("utf-8"))
128+
final_hash_hex = final_hash.hexdigest()
129+
130+
if final_hash_hex != self.hash:
131+
raise ValidationError("Failed to verify hash.")
96132

97133
def to_json(self) -> dict[str, Any]:
98134
json = {
@@ -117,17 +153,17 @@ def to_json(self) -> dict[str, Any]:
117153
return json
118154

119155
@staticmethod
120-
def __parse_time(time_str: str) -> datetime:
121-
if not isinstance(time_str, str):
122-
raise ValidationError(f"Failed to parse time '{time_str}' to datetime.")
156+
def __parse_time(time_from_server: str) -> datetime:
157+
if not isinstance(time_from_server, str):
158+
raise ValidationError(f"Failed to parse time '{time_from_server}' to datetime.")
123159

124-
rest, sub_seconds = time_str.split(".")
160+
rest, sub_seconds = time_from_server.split(".")
125161
sub_seconds = f"{sub_seconds[:6]:06}"
126162
try:
127163
return datetime.fromisoformat(f"{rest}.{sub_seconds}")
128164
except ValueError as value_error:
129165
raise ValidationError(
130-
f"Failed to parse time '{time_str}' to datetime."
166+
f"Failed to parse time '{time_from_server}' to datetime."
131167
) from value_error
132168
except Exception as other_error:
133169
raise InternalError(str(other_error)) from other_error

tests/event/__init__.py

Whitespace-only changes.

tests/event/test_verify_hash.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pytest
2+
3+
from eventsourcingdb import EventCandidate
4+
from eventsourcingdb.errors.validation_error import ValidationError
5+
from hashlib import sha256
6+
7+
from ..conftest import TestData
8+
from ..shared.database import Database
9+
10+
11+
class TestVerifyHash:
12+
@staticmethod
13+
@pytest.mark.asyncio
14+
async def test_verifies_the_event_hash(
15+
database: Database,
16+
test_data: TestData,
17+
) -> None:
18+
client = database.get_client()
19+
20+
written_events = await client.write_events(
21+
[
22+
EventCandidate(
23+
source=test_data.TEST_SOURCE_STRING, subject="/test", type="io.eventsourcingdb.test", data={"value": 23}
24+
)
25+
],
26+
)
27+
28+
assert len(written_events) == 1
29+
30+
written_event = written_events[0]
31+
written_event.verify_hash()
32+
33+
@staticmethod
34+
@pytest.mark.asyncio
35+
async def test_fails_if_the_event_hash_is_invalid(
36+
database: Database,
37+
test_data: TestData,
38+
) -> None:
39+
client = database.get_client()
40+
41+
written_events = await client.write_events(
42+
[
43+
EventCandidate(
44+
source=test_data.TEST_SOURCE_STRING, subject="/test", type="io.eventsourcingdb.test", data={"value": 23}
45+
)
46+
],
47+
)
48+
49+
assert len(written_events) == 1
50+
51+
written_event = written_events[0]
52+
53+
invalid_hash_data = "invalid data".encode("utf-8")
54+
invalid_hash = sha256(invalid_hash_data).hexdigest()
55+
written_event.hash = invalid_hash
56+
57+
with pytest.raises(ValidationError):
58+
written_event.verify_hash()

0 commit comments

Comments
 (0)