Skip to content

Commit 4ce8592

Browse files
authored
✨ Fix deserialization issues (#139)
* ✨ Create make method for ElectionContext - Create functional style make method for ElectionContext - Integrate it into Tests * ✨ Create make method for CiphertextBallots - Create make method for CiphertextAcceptedBallot and CiphertextBallot - Update use cases - Remove todo * ✨ Add datetime to serialize / deserialize * 🐛 Remove Proof serialization * ✨ Allow conversion from str for mpz - Cast str to int ahead of cast * ✨ Alteration to dataclass usage - Add equality statements - Remove frozen - Use unsafe_hash to remove unnecessary hash function * ♻️ Refactor all() for BallotStore - Ballot store will now always return Ballots for all() method. This is the main use case and reduces the need to check for null around the store. Fixed attached integration test. * ✅Deserialization Tests and Fixes - ✨Remove InitVar for Plaintext Tally - Remove InitVar for Selection - Remove PostVar - Plaintext Tally Changes - Sample Generator Fixes * 🐛 Handle incorrect from_json_file * 🐛 Handle None deserialization "None" created issues due Optional that also have custom deserialization. The solution for this is ensuring all NoneTypes are checked against a string before running default deserialization. - Suppress Warning in serializable * 📝Publish and Verify documentation
1 parent 7c6238c commit 4ce8592

23 files changed

Lines changed: 569 additions & 205 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Step-by-Step Process:
8181
2. [Encrypt Ballots]
8282
3. [Cast and Spoil]
8383
4. [Decrypt Tally]
84+
5. [Publish and Verify]
8485

8586
## Contributing
8687

@@ -148,4 +149,6 @@ A huge thank you to those who helped to contribute to this project so far, inclu
148149

149150
[Decrypt Tally]: https://github.com/microsoft/electionguard-python/blob/main/docs/4_Decrypt_Tally.md
150151

152+
[Publish and Verify]: https://github.com/microsoft/electionguard-python/blob/main/docs/5_Publish_and_Verify.md)
153+
151154
[MIT License]: https://github.com/microsoft/electionguard-python/blob/main/LICENSE

docs/5_Publish_and_Verify.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Publish and Verify
2+
3+
## Publish
4+
5+
Publishing the election artifacts helps ensure third parties can verify the election. `publish.py` provides a publish method that serializes the key election artifacts. This makes use of the `Serializable` class exists to allow easy serializing to json files. These JSON files can then be shared and sent so others can verify.
6+
7+
## Verify
8+
9+
Deserializing is the first step to verification. The `from_json` and `from_json_file` methods on `Serializable` are available to deserialize output JSON files back into their original classes.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Step-by-Step Process:
7878
2. [Encrypt Ballots](2_Encrypt_Ballots.md)
7979
3. [Cast and Spoil](3_Cast_and_Spoil.md)
8080
4. [Decrypt Tally](4_Decrypt_Tally.md)
81+
5. [Publish and Verify](5_Publish_and_Verify.md)
8182

8283
## ❓Questions
8384

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ nav:
1919
- 2. Encrypt Ballots: 2_Encrypt_Ballots.md
2020
- 3. Cast and Spoil: 3_Cast_and_Spoil.md
2121
- 4. Decrypt Tally: 4_Decrypt_Tally.md
22+
- 5. Publish and Verify: 5_Publish_and_Verify.md
2223
theme: readthedocs

src/electionguard/ballot.py

Lines changed: 171 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from dataclasses import dataclass, field
1+
from dataclasses import dataclass, field, replace
22
from datetime import datetime
33
from distutils import util
44
from enum import Enum
5-
from distutils import util
65
from typing import Any, List, Optional, Protocol, runtime_checkable, Sequence
76

87
from .chaum_pedersen import (
@@ -32,7 +31,7 @@ def _list_eq(
3231
)
3332

3433

35-
@dataclass
34+
@dataclass(eq=True, unsafe_hash=True)
3635
class ExtendedData(object):
3736
"""
3837
ExtendedData represents any arbitrary data expressible as a string with a length.
@@ -43,18 +42,8 @@ class ExtendedData(object):
4342
value: str
4443
length: int
4544

46-
def __eq__(self, other: Any) -> bool:
47-
return (
48-
isinstance(other, ExtendedData)
49-
and self.value == other.value
50-
and self.length == other.length
51-
)
5245

53-
def __ne__(self, other: Any) -> bool:
54-
return not self.__eq__(other)
55-
56-
57-
@dataclass
46+
@dataclass(unsafe_hash=True)
5847
class PlaintextBallotSelection(ElectionObjectBase):
5948
"""
6049
A BallotSelection represents an individual selection on a ballot.
@@ -128,6 +117,7 @@ def to_int(self) -> int:
128117
def __eq__(self, other: Any) -> bool:
129118
return (
130119
isinstance(other, PlaintextBallotSelection)
120+
and self.object_id == other.object_id
131121
and self.vote == other.vote
132122
and self.is_placeholder_selection == other.is_placeholder_selection
133123
and self.extended_data == other.extended_data
@@ -152,7 +142,7 @@ class CiphertextSelection(Protocol):
152142
"""The encrypted representation of the selection"""
153143

154144

155-
@dataclass
145+
@dataclass(eq=True, unsafe_hash=True)
156146
class CiphertextBallotSelection(
157147
ElectionObjectBase, CiphertextSelection, CryptoHashCheckable
158148
):
@@ -312,7 +302,7 @@ def make_ciphertext_ballot_selection(
312302
)
313303

314304

315-
@dataclass
305+
@dataclass(unsafe_hash=True)
316306
class PlaintextBallotContest(ElectionObjectBase):
317307
"""
318308
A PlaintextBallotContest represents the selections made by a voter for a specific ContestDescription
@@ -388,7 +378,7 @@ def __ne__(self, other: Any) -> bool:
388378
return not self.__eq__(other)
389379

390380

391-
@dataclass
381+
@dataclass(unsafe_hash=True)
392382
class CiphertextBallotContest(ElectionObjectBase, CryptoHashCheckable):
393383
"""
394384
A CiphertextBallotContest represents the selections made by a voter for a specific ContestDescription
@@ -423,6 +413,20 @@ class CiphertextBallotContest(ElectionObjectBase, CryptoHashCheckable):
423413
available selections for the contest, and that the proof was generated with the nonce
424414
"""
425415

416+
def __eq__(self, other: Any) -> bool:
417+
return (
418+
isinstance(other, CiphertextBallotContest)
419+
and self.object_id == other.object_id
420+
and _list_eq(self.ballot_selections, other.ballot_selections)
421+
and self.description_hash == other.description_hash
422+
and self.crypto_hash == other.crypto_hash
423+
and self.nonce == other.nonce
424+
and self.proof == other.proof
425+
)
426+
427+
def __ne__(self, other: Any) -> bool:
428+
return not self.__eq__(other)
429+
426430
def aggregate_nonce(self) -> Optional[ElementModQ]:
427431
"""
428432
:return: an aggregate nonce for the contest composed of the nonces of the selections
@@ -578,7 +582,7 @@ def make_ciphertext_ballot_contest(
578582
)
579583

580584

581-
@dataclass
585+
@dataclass(unsafe_hash=True)
582586
class PlaintextBallot(ElectionObjectBase):
583587
"""
584588
A PlaintextBallot represents a voters selections for a given ballot and ballot style
@@ -616,14 +620,16 @@ def __ne__(self, other: Any) -> bool:
616620
return not self.__eq__(other)
617621

618622

619-
@dataclass
623+
@dataclass(unsafe_hash=True)
620624
class CiphertextBallot(ElectionObjectBase, CryptoHashCheckable):
621625
"""
622-
A CiphertextBallot represents a voters encrypted selections for a given ballot and ballot style
626+
A CiphertextBallot represents a voters encrypted selections for a given ballot and ballot style.
623627
624628
When a ballot is in it's complete, encrypted state, the `nonce` is the master nonce
625629
from which all other nonces can be derived to encrypt the ballot. Allong with the `nonce`
626630
fields on `Ballotcontest` and `BallotSelection`, this value is sensitive.
631+
632+
Don't make this directly. Use `make_ciphertext_ballot` instead.
627633
:field object_id: A unique Ballot ID that is relevant to the external system
628634
"""
629635

@@ -639,22 +645,34 @@ class CiphertextBallot(ElectionObjectBase, CryptoHashCheckable):
639645
contests: List[CiphertextBallotContest]
640646
"""List of contests for this ballot"""
641647

642-
tracking_hash: Optional[ElementModQ] = field(init=False)
648+
tracking_hash: Optional[ElementModQ]
643649
"""Unique ballot tracking hash for this ballot"""
644650

645-
timestamp: int = field(init=False)
651+
timestamp: int
646652
"""Timestamp at which the ballot encryption is generated in tick"""
647653

648-
crypto_hash: ElementModQ = field(init=False)
654+
crypto_hash: ElementModQ
649655
"""The hash of the encrypted ballot representation"""
650656

651-
nonce: Optional[ElementModQ] = field(default=None)
657+
nonce: Optional[ElementModQ]
652658
"""The nonce used to encrypt this ballot. Sensitive & should be treated as a secret"""
653659

654-
def __post_init__(self) -> None:
655-
self.crypto_hash = self.crypto_hash_with(self.description_hash)
656-
self.timestamp = to_ticks(datetime.utcnow())
657-
self.generate_tracking(self.previous_tracking_hash)
660+
def __eq__(self, other: Any) -> bool:
661+
return (
662+
isinstance(other, CiphertextBallot)
663+
and self.object_id == other.object_id
664+
and self.ballot_style == other.ballot_style
665+
and self.description_hash == other.description_hash
666+
and self.previous_tracking_hash == other.previous_tracking_hash
667+
and _list_eq(self.contests, other.contests)
668+
and self.tracking_hash == other.tracking_hash
669+
and self.timestamp == other.timestamp
670+
and self.crypto_hash == other.crypto_hash
671+
and self.nonce == other.nonce
672+
)
673+
674+
def __ne__(self, other: Any) -> bool:
675+
return not self.__eq__(other)
658676

659677
@staticmethod
660678
def nonce_seed(
@@ -680,16 +698,6 @@ def hashed_ballot_nonce(self) -> Optional[ElementModQ]:
680698

681699
return self.nonce_seed(self.description_hash, self.object_id, self.nonce)
682700

683-
def generate_tracking(self, seed_hash: ElementModQ) -> None:
684-
"""
685-
Generate a tracking hash from given hash and existing ballot hash
686-
:param seed_hash: Seed hash whether starting or previous
687-
"""
688-
self.previous_tracking_hash = seed_hash
689-
self.tracking_hash = get_rotating_tracker_hash(
690-
self.previous_tracking_hash, self.timestamp, self.crypto_hash
691-
)
692-
693701
def get_tracker_code(self) -> Optional[str]:
694702
"""
695703
Get a tracker hash as a code in friendly readable words for sharing
@@ -787,51 +795,154 @@ class BallotBoxState(Enum):
787795
"""
788796

789797

790-
@dataclass
798+
@dataclass(unsafe_hash=True)
791799
class CiphertextAcceptedBallot(CiphertextBallot):
792800
"""
793-
a `CiphertextAcceptedBallot` represents a ballot that is accepted for inclusion in election results.
794-
an accepted ballot is or is about to be either cast or spoiled.
801+
A `CiphertextAcceptedBallot` represents a ballot that is accepted for inclusion in election results.
802+
An accepted ballot is or is about to be either cast or spoiled.
795803
The state supports the `BallotBoxState.UNKNOWN` enumeration to indicate that this object is mutable
796804
and has not yet been explicitly assigned a specific state.
797805
798-
note, additionally, this ballot includes all proofs but no nonces
806+
Note, additionally, this ballot includes all proofs but no nonces.
807+
808+
Do not make this class directly. Use `make_ciphertext_accepted_ballot` or `from_ciphertext_ballot` instead.
799809
"""
800810

801-
tracking_hash: Optional[ElementModQ] = None
802-
timestamp: int = 0
803-
state: BallotBoxState = field(default=BallotBoxState.UNKNOWN)
811+
state: BallotBoxState
812+
813+
def __eq__(self, other: Any) -> bool:
814+
return (
815+
isinstance(other, CiphertextAcceptedBallot)
816+
and super.__eq__(self, other)
817+
and self.state == other.state
818+
)
819+
820+
def __ne__(self, other: Any) -> bool:
821+
return not self.__eq__(other)
822+
823+
824+
def make_ciphertext_ballot(
825+
object_id: str,
826+
ballot_style: str,
827+
description_hash: ElementModQ,
828+
previous_tracking_hash: Optional[ElementModQ],
829+
contests: List[CiphertextBallotContest],
830+
nonce: Optional[ElementModQ] = None,
831+
timestamp: Optional[int] = None,
832+
tracking_hash: Optional[ElementModQ] = None,
833+
) -> CiphertextBallot:
834+
"""
835+
Makes a `CiphertextBallot`, initially in the state where it's neither been cast nor spoiled.
836+
837+
:param object_id: the object_id of this specific ballot
838+
:param ballot_style: The `object_id` of the `BallotStyle` in the `Election` Manifest
839+
:param description_hash: Hash of the election metadata
840+
:param crypto_base_hash: Hash of the cryptographic election context
841+
:param contests: List of contests for this ballot
842+
:param timestamp: Timestamp at which the ballot encryption is generated in tick
843+
:param previous_tracking_hash: Previous tracking hash or seed hash
844+
:param nonce: optional nonce used as part of the encryption process
845+
"""
846+
847+
if len(contests) == 0:
848+
log_warning(f"ciphertext ballot with no contests")
849+
850+
contest_hashes = [contest.crypto_hash for contest in contests]
851+
contest_hash = hash_elems(object_id, description_hash, *contest_hashes)
852+
853+
timestamp = to_ticks(datetime.utcnow()) if timestamp is None else timestamp
854+
if previous_tracking_hash is None:
855+
previous_tracking_hash = description_hash
856+
if tracking_hash is None:
857+
tracking_hash = get_rotating_tracker_hash(
858+
previous_tracking_hash, timestamp, contest_hash
859+
)
860+
861+
return CiphertextBallot(
862+
object_id=object_id,
863+
ballot_style=ballot_style,
864+
description_hash=description_hash,
865+
previous_tracking_hash=previous_tracking_hash,
866+
contests=contests,
867+
tracking_hash=tracking_hash,
868+
timestamp=timestamp,
869+
nonce=nonce,
870+
crypto_hash=contest_hash,
871+
)
872+
873+
874+
def make_ciphertext_accepted_ballot(
875+
object_id: str,
876+
ballot_style: str,
877+
description_hash: ElementModQ,
878+
previous_tracking_hash: Optional[ElementModQ],
879+
contests: List[CiphertextBallotContest],
880+
tracking_hash: Optional[ElementModQ],
881+
timestamp: Optional[int] = None,
882+
state: BallotBoxState = BallotBoxState.UNKNOWN,
883+
) -> CiphertextAcceptedBallot:
804884
"""
805-
the state of the ballot
885+
Makes a `CiphertextAcceptedBallot`, ensuring that no nonces are part of the contests.
886+
887+
:param object_id: the object_id of this specific ballot
888+
:param ballot_style: The `object_id` of the `BallotStyle` in the `Election` Manifest
889+
:param description_hash: Hash of the election metadata
890+
:param previous_tracking_hash: Previous tracking hash or seed hash
891+
:param contests: List of contests for this ballot
892+
:param timestamp: Timestamp at which the ballot encryption is generated in tick
893+
:param state: ballot box state
806894
"""
807895

808-
def __post_init__(self,) -> None:
809-
super().__post_init__()
896+
if len(contests) == 0:
897+
log_warning(f"ciphertext ballot with no contests")
810898

811-
# HACK: ISSUE: #45: Accepted ballots should not have a nonce assoiciated with them
899+
contest_hashes = [contest.crypto_hash for contest in contests]
900+
contest_hash = hash_elems(object_id, description_hash, *contest_hashes)
812901

813-
for contest in self.contests:
814-
for selection in contest.ballot_selections:
815-
selection.nonce = None
816-
contest.nonce = None
902+
timestamp = to_ticks(datetime.utcnow()) if timestamp is None else timestamp
903+
if previous_tracking_hash is None:
904+
previous_tracking_hash = description_hash
905+
if tracking_hash is None:
906+
tracking_hash = get_rotating_tracker_hash(
907+
previous_tracking_hash, timestamp, contest_hash
908+
)
817909

818-
self.nonce = None
910+
# copy the contests and selections, removing all nonces
911+
new_contests: List[CiphertextBallotContest] = []
912+
for contest in contests:
913+
new_selections = [
914+
replace(selection, nonce=None) for selection in contest.ballot_selections
915+
]
916+
new_contest = replace(contest, nonce=None, ballot_selections=new_selections)
917+
new_contests.append(new_contest)
918+
919+
return CiphertextAcceptedBallot(
920+
object_id=object_id,
921+
ballot_style=ballot_style,
922+
description_hash=description_hash,
923+
previous_tracking_hash=previous_tracking_hash,
924+
contests=new_contests,
925+
tracking_hash=tracking_hash,
926+
timestamp=timestamp,
927+
crypto_hash=contest_hash,
928+
nonce=None,
929+
state=state,
930+
)
819931

820932

821933
def from_ciphertext_ballot(
822-
ballot: CiphertextBallot, state: BallotBoxState
934+
ballot: CiphertextBallot, state: BallotBoxState = BallotBoxState.UNKNOWN
823935
) -> CiphertextAcceptedBallot:
824936
"""
825-
Convert a `CiphertextBallot` into a `CiphertextAcceptedBallot` with the correct state
937+
Convert a `CiphertextBallot` into a `CiphertextAcceptedBallot`, with all nonces removed.
826938
"""
827-
828-
return CiphertextAcceptedBallot(
939+
return make_ciphertext_accepted_ballot(
829940
object_id=ballot.object_id,
830941
ballot_style=ballot.ballot_style,
831942
description_hash=ballot.description_hash,
832-
previous_tracking_hash=ballot.previous_tracking_hash,
833943
contests=ballot.contests,
834-
tracking_hash=ballot.tracking_hash,
835944
timestamp=ballot.timestamp,
945+
previous_tracking_hash=ballot.previous_tracking_hash,
946+
tracking_hash=ballot.tracking_hash,
836947
state=state,
837948
)

0 commit comments

Comments
 (0)