Repository: microsoft/electionguard-python

Issue:
For ElementModQ, q1 == q2 does not imply that hash(q1) == hash(q2)
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

For ElementModQ, q1 == q2 does not imply that hash(q1) == hash(q2)

```
    def testHexToQ(self) -> None :
        s1q = hex_to_q("C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        s2q = hex_to_q("0C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.assertEqual(s1q, s2q)
        self.assertNotEqual(hash_elems(s1q), hash_elems(s2q))
        self.assertNotEqual(s1q.to_hex(), s2q.to_hex())
```

its suprising that leading zeroes are retained, even past the 64 char maximum:

```
    def testHexString(self) -> None :
        self.show("A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("9A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("0C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("00C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("000C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.show("0000C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")

    def show(self, s1 : str) -> None :
        s1q = hex_to_q(s1)
        print(f" len = {len(s1)} hex = {s1q.to_hex()}")
```

gives:

```
 len = 61 hex = A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 62 hex = 9A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 63 hex = 49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 64 hex = C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 65 hex = 0C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 66 hex = 00C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 67 hex = 000C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
 len = 68 hex = 0000C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206
```



### Expected Behavior

Should be:

```
    def testHexToQ(self) -> None :
        s1q = hex_to_q("C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        s2q = hex_to_q("0C49A1E8053FBA95F6B7CD3F3B30B101CDD595C435A46AECF2872F47F1C601206")
        self.assertEqual(s1q, s2q)
        self.assertEqual(hash_elems(s1q), hash_elems(s2q))
        self.assertEqual(s1q.to_hex(), s2q.to_hex())
```

would recommend that the hex value be exactly 64 characters, with leading zeros truncated or padded as needed.

### Steps To Reproduce

see above

### Environment

```markdown
- OS: all
```


### Anything else?

The same is probably true for ElementModP

In general, the hash function is underspecified.
Porting it to other languages is problematic, since there is no specification other than the python code.
There is a small enough set of argument types to hash_elems() that specifying it should not be too onerous to do.
Then one can test to look for other possible edge cases and make sure there is no ambiguity.

Comments:

Patch:
diff --git a/src/electionguard/big_integer.py b/src/electionguard/big_integer.py
index efacaa5..80f72e9 100644
--- a/src/electionguard/big_integer.py
+++ b/src/electionguard/big_integer.py
@@ -9,7 +9,9 @@ from .utils import BYTE_ORDER
 
 def _hex_to_int(input: str) -> int:
     """Given a hex string representing bytes, returns an int."""
-    return int(input, 16)
+    valid_bytes = input[1:] if (len(input) % 2 != 0 and input[0] == "0") else input
+    hex_bytes = bytes.fromhex(valid_bytes)
+    return int.from_bytes(hex_bytes, BYTE_ORDER)
 
 
 def _int_to_hex(input: int) -> str:
@@ -33,8 +35,8 @@ _zero = mpz(0)
 def _convert_to_element(data: Union[int, str]) -> Tuple[str, int]:
     """Convert element to consistent types"""
     if isinstance(data, str):
-        hex = data
         integer = _hex_to_int(data)
+        hex = _int_to_hex(integer)
     else:
         hex = _int_to_hex(data)
         integer = data


Test patch:
diff --git a/tests/property/test_hash.py b/tests/property/test_hash.py
index 899dc79..540648e 100644
--- a/tests/property/test_hash.py
+++ b/tests/property/test_hash.py
@@ -1,10 +1,13 @@
 from typing import List, Optional
 
 from hypothesis import given
+from hypothesis.strategies import integers
+
 
 from tests.base_test_case import BaseTestCase
 
-from electionguard.group import ElementModQ
+from electionguard.big_integer import BigInteger
+from electionguard.group import ElementModP, ElementModQ
 from electionguard.hash import hash_elems
 from electionguard_tools.strategies.group import elements_mod_p, elements_mod_q
 
@@ -28,6 +31,43 @@ class TestHash(BaseTestCase):
         if ha != hb:
             self.assertNotEqual(a, b)
 
+    @given(elements_mod_p(), integers(min_value=0, max_value=10))
+    def test_hash_of_big_integer_with_leading_zero_bytes(
+        self, input: ElementModP, multiplier: int
+    ) -> None:
+        """Test hashing of larger integers with leading zero bytes"""
+
+        # Arrange.
+        zero_byte = "00"
+        input_hash = hash_elems(input)
+        leading_zeroes = zero_byte * multiplier + input.to_hex()
+
+        # Act.
+        leading_zeroes_big_int = BigInteger(leading_zeroes)
+        leading_zeroes_hash = hash_elems(leading_zeroes_big_int)
+
+        # Assert.
+        self.assertEqual(input, leading_zeroes_big_int)
+        self.assertEqual(input_hash, leading_zeroes_hash)
+
+    @given(elements_mod_p())
+    def test_hash_of_big_integer_with_single_leading_zero(
+        self, input: ElementModP
+    ) -> None:
+        """Test hashing of big integer with a single leading zero creating an invalid hex byte reprsentation."""
+
+        # Arrange.
+        invalid_hex = "0" + input.to_hex()
+        input_hash = hash_elems(input)
+
+        # Act.
+        invalid_hex_big_int = BigInteger(invalid_hex)
+        invalid_hex_hash = hash_elems(invalid_hex_big_int)
+
+        # Assert.
+        self.assertEqual(input, invalid_hex_big_int)
+        self.assertEqual(input_hash, invalid_hex_hash)
+
     def test_hash_for_zero_number_is_zero_string(self):
         self.assertEqual(hash_elems(0), hash_elems("0"))