Repository: microsoft/electionguard-python

Issue:
🐛 Datetime tick generation is inconsistent
When a ballot is encrypted using this library, and a `timestamp` is not provided, the tracking hash is computed with `utils.to_ticks(datetime.utcnow())`.  Both `to_ticks` and `utcnow` have some problems:

- `to_ticks` only works on naive timezone-unaware datetimes (e.g. created with `now` or `utcnow`).  Using it on deserialized ISO-8601 timestamps will fail
- `to_ticks` is based on the date `0001-01-01T00:00:00` in naive timezone-unaware form, which:
  - creates very large tick values and risks int overflow
  - is the absolute minimum that datetime can represent, and attempts to normalize to a timezone cause errors
- `to_ticks` returns the number of seconds * 10e7, which is one one hundred millionth of a second - in between microseconds and nanoseconds.  This inflates the size of the tick number massively, and is a non-standard unit.
- `datetime.utcnow` produces the date/time in UTC, [but doesn't capture the timezone](https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow).  Most other `datetime` logic (including the time math currently in `to_ticks`) assumes this is a local time, and performs the wrong calculations.

To address this, we've decided to switch to using the unix epoch (seconds since January 1, 1970 UTC).  It's unambiguous and very common.

Comments:

Patch:
diff --git a/src/electionguard/ballot.py b/src/electionguard/ballot.py
index b45c4fe..cbe7fc4 100644
--- a/src/electionguard/ballot.py
+++ b/src/electionguard/ballot.py
@@ -850,7 +850,7 @@ def make_ciphertext_ballot(
     contest_hashes = [contest.crypto_hash for contest in contests]
     contest_hash = hash_elems(object_id, description_hash, *contest_hashes)
 
-    timestamp = to_ticks(datetime.utcnow()) if timestamp is None else timestamp
+    timestamp = to_ticks(datetime.now()) if timestamp is None else timestamp
     if previous_tracking_hash is None:
         previous_tracking_hash = description_hash
     if tracking_hash is None:
diff --git a/src/electionguard/utils.py b/src/electionguard/utils.py
index 20b12c8..11deea4 100644
--- a/src/electionguard/utils.py
+++ b/src/electionguard/utils.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 from os import mkdir, path
 from re import sub
 from typing import Callable, Optional, TypeVar
@@ -66,14 +66,18 @@ def flatmap_optional(optional: Optional[T], mapper: Callable[[T], U]) -> Optiona
 
 def to_ticks(date_time: datetime) -> int:
     """
-    Return the number of ticks for a date time
+    Return the number of ticks for a date time.
+    Ticks are defined here as number of seconds since the unix epoch (00:00:00 UTC on 1 January 1970)
     :param date_time: Date time to convert
     :return: number of ticks
     """
-    t0 = datetime(1, 1, 1)
-    seconds = int((date_time - t0).total_seconds())
-    ticks = seconds * 10 ** 7
-    return ticks
+
+    ticks = (
+        date_time.timestamp()
+        if date_time.tzinfo
+        else date_time.astimezone(timezone.utc).timestamp()
+    )
+    return int(ticks)
 
 
 def space_between_capitals(base: str) -> str:


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 1b284eb..9d978ca 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,13 +1,53 @@
 from unittest import TestCase
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
 
 from electionguard.utils import to_ticks
 
 
 class TestUtils(TestCase):
-    def test_conversion_to_ticks(self):
+    def test_conversion_to_ticks_from_utc(self):
         # Act
-        ticks = to_ticks(datetime.utcnow())
+        ticks = to_ticks(datetime.now(timezone.utc))
 
         self.assertIsNotNone(ticks)
         self.assertGreater(ticks, 0)
+
+    def test_conversion_to_ticks_from_local(self):
+        # Act
+        ticks = to_ticks(datetime.now())
+
+        self.assertIsNotNone(ticks)
+        self.assertGreater(ticks, 0)
+
+    def test_conversion_to_ticks_with_tz(self):
+        # Arrange
+        now = datetime.now()
+        now_with_tz = (now).astimezone()
+        now_utc_with_tz = now_with_tz.astimezone(timezone.utc)
+
+        # Act
+        ticks_now = to_ticks(now)
+        ticks_local = to_ticks(now_with_tz)
+        ticks_utc = to_ticks(now_utc_with_tz)
+
+        # Assert
+        self.assertIsNotNone(ticks_now)
+        self.assertIsNotNone(ticks_local)
+        self.assertIsNotNone(ticks_utc)
+
+        # Ensure all three tick values are the same
+        unique_ticks = set([ticks_now, ticks_local, ticks_utc])
+        self.assertEqual(1, len(unique_ticks))
+        self.assertTrue(ticks_now in unique_ticks)
+
+    def test_conversion_to_ticks_produces_valid_epoch(self):
+        # Arrange
+        now = datetime.now()
+
+        # Act
+        ticks_now = to_ticks(now)
+        now_from_ticks = datetime.fromtimestamp(ticks_now)
+
+        # Assert
+        # Values below seconds are dropped from the epoch
+        self.assertEqual(now.replace(microsecond=0), now_from_ticks)