Skip to content

Commit 4e498f2

Browse files
bvanelliclaude
andauthored
fix: Improve HULC timestamp counter with overflow detection and clock drift protection. (#196)
- Add `ActualOverflowError` for when the HULC counter exceeds `0xFFFF` within the same millisecond - Protect against clock drift by ensuring logical time never goes backward - Truncate timestamps to millisecond precision to match Node.js behavior - Refactor `MessageEnvelope` to expose a `.message()` helper, reducing duplication in `SyncResponse.get_messages()` Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 88d5780 commit 4e498f2

3 files changed

Lines changed: 77 additions & 19 deletions

File tree

actual/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ class ActualDecryptionError(ActualError):
8282
pass
8383

8484

85+
class ActualOverflowError(ActualError):
86+
"""The HULC timestamp counter exceeded the maximum value of 0xFFFF (65535).
87+
88+
This means too many messages were generated within the same millisecond.
89+
"""
90+
91+
pass
92+
93+
8594
class ActualSplitTransactionError(ActualError):
8695
"""The split transaction is invalid.
8796

actual/protobuf_models.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import proto
88

99
from actual.crypto import decrypt, encrypt
10-
from actual.exceptions import ActualDecryptionError
10+
from actual.exceptions import ActualDecryptionError, ActualOverflowError
1111

1212
"""
1313
Protobuf message definitions taken from the [sync.proto file](
@@ -26,10 +26,12 @@ class HULC_Client:
2626
clocks.
2727
"""
2828

29+
MAX_COUNTER: int = 0xFFFF
30+
2931
def __init__(self, client_id: str | None = None, initial_count: int = 0, ts: datetime.datetime | None = None):
3032
self.client_id = client_id or self.random_client_id()
3133
self.initial_count = initial_count
32-
self.ts = ts or datetime.datetime(1970, 1, 1, 0, 0, 0)
34+
self.ts = ts
3335

3436
@classmethod
3537
def from_timestamp(cls, ts: str) -> HULC_Client:
@@ -40,8 +42,8 @@ def from_timestamp(cls, ts: str) -> HULC_Client:
4042
return cls(segments[-1], int(segments[-2], 16), parsed_ts)
4143

4244
def __str__(self):
43-
count = f"{self.initial_count:0>4X}"
44-
return f"{self.ts.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}"
45+
ts = self.ts or datetime.datetime(1970, 1, 1, 0, 0, 0)
46+
return f"{ts.isoformat(timespec='milliseconds')}Z-{self.initial_count:0>4X}-{self.client_id}"
4547

4648
def timestamp(self, now: datetime.datetime | None = None) -> str:
4749
"""Actual uses Hybrid Unique Logical Clock (HULC) timestamp generator.
@@ -55,11 +57,23 @@ def timestamp(self, now: datetime.datetime | None = None) -> str:
5557
https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts)
5658
for reference.
5759
"""
58-
if not now:
59-
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
60-
count = f"{self.initial_count:0>4X}"
61-
self.initial_count += 1
62-
return f"{now.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}"
60+
current_time = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) if now is None else now
61+
# truncate to millisecond precision to match the Node.js Date.now() behavior
62+
current_time = current_time.replace(microsecond=current_time.microsecond // 1000 * 1000)
63+
# ensure that the logical time never goes backward
64+
new_logical_time = current_time if self.ts is None else max(self.ts, current_time)
65+
# advance the counter if same millisecond, otherwise reset to 0
66+
new_counter = self.initial_count + 1 if (self.ts is not None and self.ts == new_logical_time) else 0
67+
68+
if new_counter > self.MAX_COUNTER:
69+
raise ActualOverflowError(
70+
f"Timestamp counter overflow (>{self.MAX_COUNTER}). "
71+
"Too many sync messages were generated without the clock advancing."
72+
)
73+
74+
self.ts = new_logical_time
75+
self.initial_count = new_counter
76+
return str(self)
6377

6478
@staticmethod
6579
def random_client_id():
@@ -135,6 +149,16 @@ def set_timestamp(
135149
self.timestamp = HULC_Client(client_id, initial_count).timestamp(now)
136150
return self.timestamp
137151

152+
def message(self, master_key: bytes | None = None) -> Message:
153+
if self.isEncrypted:
154+
if not master_key:
155+
raise ActualDecryptionError("Master key not provided and data is encrypted.")
156+
encrypted = EncryptedData.deserialize(self.content)
157+
content = decrypt(master_key, encrypted.iv, encrypted.data, encrypted.authTag)
158+
else:
159+
content = self.content
160+
return Message.deserialize(content)
161+
138162

139163
class SyncRequest(proto.Message):
140164
"""Sync request message that is sent to the server for retrieving new messages since the last synchronization."""
@@ -185,12 +209,5 @@ class SyncResponse(proto.Message):
185209
def get_messages(self, master_key: bytes | None = None) -> list[Message]:
186210
messages = []
187211
for message in self.messages: # noqa
188-
if message.isEncrypted:
189-
if not master_key:
190-
raise ActualDecryptionError("Master key not provided and data is encrypted.")
191-
encrypted = EncryptedData.deserialize(message.content)
192-
content = decrypt(master_key, encrypted.iv, encrypted.data, encrypted.authTag)
193-
else:
194-
content = message.content
195-
messages.append(Message.deserialize(content))
212+
messages.append(message.message(master_key))
196213
return messages

tests/test_protobuf.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import datetime
22

33
import pytest
4+
from freezegun import freeze_time
45

6+
from actual.exceptions import ActualOverflowError
57
from actual.protobuf_models import (
68
HULC_Client,
79
Message,
@@ -11,11 +13,41 @@
1113
)
1214

1315

16+
@freeze_time("2020-10-11 12:13:14.015")
1417
def test_timestamp():
15-
now = datetime.datetime(2020, 10, 11, 12, 13, 14, 15 * 1000)
16-
ts = HULC_Client("foo").timestamp(now)
18+
client = HULC_Client("foo")
19+
ts = client.timestamp()
1720
assert ts == "2020-10-11T12:13:14.015Z-0000-foo"
1821
assert "foo" == HULC_Client.from_timestamp(ts).client_id
22+
# Next ts should have advanced the counter
23+
next_ts = client.timestamp()
24+
assert next_ts == "2020-10-11T12:13:14.015Z-0001-foo"
25+
26+
27+
@freeze_time("2020-10-11 12:13:14.015")
28+
def test_timestamp_client_string():
29+
client = HULC_Client("foo")
30+
assert str(client) == "1970-01-01T00:00:00.000Z-0000-foo"
31+
assert client.timestamp(datetime.datetime.fromtimestamp(0)) == "1970-01-01T00:00:00.000Z-0000-foo"
32+
33+
34+
def test_timestamp_counter_reset_on_clock_advance():
35+
now = datetime.datetime(2020, 10, 11, 12, 13, 14, 15_000)
36+
client = HULC_Client("foo", initial_count=5, ts=now)
37+
# same timestamp: counter advances
38+
ts = client.timestamp(now)
39+
assert ts == "2020-10-11T12:13:14.015Z-0006-foo"
40+
# clock advances by 1ms: counter resets to 0
41+
later = datetime.datetime(2020, 10, 11, 12, 13, 14, 16_000)
42+
ts = client.timestamp(later)
43+
assert ts == "2020-10-11T12:13:14.016Z-0000-foo"
44+
45+
46+
def test_timestamp_counter_overflow():
47+
now = datetime.datetime(2020, 10, 11, 12, 13, 14, 15_000)
48+
client = HULC_Client("foo", initial_count=0xFFFF, ts=now)
49+
with pytest.raises(ActualOverflowError, match="Timestamp counter overflow"):
50+
client.timestamp(now) # tries 0xFFFF + 1, overflows
1951

2052

2153
def test_message_envelope():

0 commit comments

Comments
 (0)