diff --git a/Makefile b/Makefile index e3ad5f24..973d4b21 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ environment: poetry config virtualenvs.in-project true poetry install @echo 🚨 Be sure to add poetry to PATH - make fetch-sample-data +# make fetch-sample-data install: @echo πŸ”§ INSTALL diff --git a/data/election_manifest_simple.json b/data/election_manifest_simple.json index 3533ccf0..77f142a9 100644 --- a/data/election_manifest_simple.json +++ b/data/election_manifest_simple.json @@ -259,7 +259,6 @@ "vote_variation": "n_of_m", "electoral_district_id": "jefferson-county", "name": "Justice of the Supreme Court", - "number_elected": 2, "votes_allowed": 2 }, { @@ -304,8 +303,62 @@ "vote_variation": "one_of_m", "electoral_district_id": "harrison-township", "name": "The Pineapple Question", - "number_elected": 1, "votes_allowed": 1 + }, + { + "object_id": "official-bagel-style", + "sequence_order": 3, + "ballot_selections": [ + { + "object_id": "new-york-style-selection", + "sequence_order": 1, + "candidate_id": "new-york-style" + }, + { + "object_id": "montreal-style-selection", + "sequence_order": 2, + "candidate_id": "montreal-style" + }, + { + "object_id": "st-louis-style-selection", + "sequence_order": 3, + "candidate_id": "st-louis-style" + }, + { + "object_id": "write-in-style-selection", + "sequence_order": 4, + "candidate_id": "write-in-style" + } + ], + "ballot_title": { + "text": [ + { + "value": "Harrison official bagel style", + "language": "en" + }, + { + "value": "Estilo bagel oficial de Harrison", + "language": "es" + } + ] + }, + "ballot_subtitle": { + "text": [ + { + "value": "Please allot up to three points", + "language": "en" + }, + { + "value": "Por favor asigne hasta tres puntos", + "language": "es" + } + ] + }, + "vote_variation": "cumulative", + "electoral_district_id": "harrison-township", + "name": "The Bagel Question", + "votes_allowed": 3, + "votes_allowed_per_selection": 3 } ], "ballot_styles": [ diff --git a/data/manifest-hamilton-general.json b/data/manifest-hamilton-general.json index 1139fe8c..c6b63b86 100644 --- a/data/manifest-hamilton-general.json +++ b/data/manifest-hamilton-general.json @@ -1232,7 +1232,6 @@ "sequence_order": 0, "electoral_district_id": "hamilton-county", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "President and Vice President of the United States", "ballot_selections": [ @@ -1302,7 +1301,6 @@ "sequence_order": 1, "electoral_district_id": "hamilton-county", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Governor of the Commonwealth of Ozark", "ballot_selections": [ @@ -1472,7 +1470,6 @@ "sequence_order": 2, "electoral_district_id": "congress-district-5", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Congressional District 5", "ballot_selections": [ @@ -1537,7 +1534,6 @@ "sequence_order": 3, "electoral_district_id": "congress-district-7", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Congressional District 7", "ballot_selections": [ @@ -1602,7 +1598,6 @@ "sequence_order": 4, "electoral_district_id": "pismo-beach-school-district-precinct-1", "vote_variation": "n_of_m", - "number_elected": 3, "votes_allowed": 3, "name": "Pismo Beach School Board", "ballot_selections": [ @@ -1687,7 +1682,6 @@ "sequence_order": 5, "electoral_district_id": "somerset-school-district-precinct-1", "vote_variation": "n_of_m", - "number_elected": 2, "votes_allowed": 2, "name": "Somerset School Board", "ballot_selections": [ @@ -1762,7 +1756,6 @@ "sequence_order": 6, "electoral_district_id": "arlington-township-precinct-1", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Retain Robert Demergue as Chief Justice?", "ballot_selections": [ @@ -1807,7 +1800,6 @@ "sequence_order": 7, "electoral_district_id": "lacroix-exeter-utility-district", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Capital Projects Levy", "ballot_selections": [ diff --git a/data/manifest-min.json b/data/manifest-min.json new file mode 100755 index 00000000..453f87c8 --- /dev/null +++ b/data/manifest-min.json @@ -0,0 +1,67 @@ +{ + "spec_version": "v0.95", + "election_scope_id": "franklin-minimal-referendum-manifest", + "type": "general", + "geopolitical_units": [ + { + "object_id": "franklin-county", + "name": "Franklin County", + "type": "municipality" + } + ], + "parties": [ + { + "object_id": "N/A" + } + ], + "candidates": [ + { + "object_id": "referendum-pineapple-affirmative" + }, + { + "object_id": "referendum-pineapple-negative" + } + ], + "contests": [ + { + "object_id": "referendum-pineapple", + "name": "Referendum for Banning Pineapple on Pizza", + "sequence_order": 0, + "vote_variation": "one_of_m", + "electoral_district_id": "franklin-county", + "votes_allowed": 1, + "ballot_selections": [ + { + "object_id": "referendum-pineapple-affirmative-selection", + "sequence_order": 0, + "candidate_id": "referendum-pineapple-affirmative" + }, + { + "object_id": "referendum-pineapple-negative-selection", + "sequence_order": 1, + "candidate_id": "referendum-pineapple-negative" + } + ] + } + ], + "ballot_styles": [ + { + "object_id": "ballot-style-01", + "geopolitical_unit_ids": ["franklin-county"] + } + ], + "name": { + "text": [ + { + "language": "en", + "value": "Franklin County Minimal General Election March 2020" + }, + { + "language": "es", + "value": "Elecciones generales mΓ­nimas del condado de Franklin marzo de 2020" + } + ] + }, + "start_date": "2020-03-01T08:00:00-05:00", + "end_date": "2020-03-03T19:00:00-05:00" +} \ No newline at end of file diff --git a/data/manifest-minimal.json b/data/manifest-minimal.json index 7cd094ed..6fb91555 100644 --- a/data/manifest-minimal.json +++ b/data/manifest-minimal.json @@ -49,7 +49,100 @@ "sequence_order": 0, "electoral_district_id": "franklin-county", "vote_variation": "one_of_m", - "number_elected": 1, + "votes_allowed": 1, + "name": "Referendum for Banning Pineapple on Pizza", + "ballot_selections": [ + { + "object_id": "referendum-pineapple-affirmative-selection", + "sequence_order": 0, + "candidate_id": "referendum-pineapple-affirmative" + }, + { + "object_id": "referendum-pineapple-negative-selection", + "sequence_order": 1, + "candidate_id": "referendum-pineapple-negative" + } + ], + "ballot_title": null, + "ballot_subtitle": null + } + ], + "ballot_styles": [ + { + "object_id": "ballot-style-01", + "geopolitical_unit_ids": [ + "franklin-county" + ], + "party_ids": null, + "image_uri": null + } + ], + "name": { + "text": [ + { + "value": "Franklin County Minimal General Election March 2020", + "language": "en" + }, + { + "value": "Elecciones generales m\u00ednimas del condado de Franklin marzo de 2020", + "language": "es" + } + ] + }, + "contact_information": null +} +======= +{ + "election_scope_id": "franklin-minimal-referendum-manifest", + "spec_version": "1.0", + "type": "general", + "start_date": "2020-03-01T08:00:00-05:00", + "end_date": "2020-03-03T19:00:00-05:00", + "geopolitical_units": [ + { + "object_id": "franklin-county", + "name": "Franklin County", + "type": "municipality", + "contact_information": null + } + ], + "parties": [ + { + "object_id": "N/A", + "name": { + "text": [] + }, + "abbreviation": null, + "color": null, + "logo_uri": null + } + ], + "candidates": [ + { + "object_id": "referendum-pineapple-affirmative", + "name": { + "text": [] + }, + "party_id": null, + "image_uri": null, + "is_write_in": null + }, + { + "object_id": "referendum-pineapple-negative", + "name": { + "text": [] + }, + "party_id": null, + "image_uri": null, + "is_write_in": null + } + ], + "contests": [ + { + "object_id": "referendum-pineapple", + "sequence_order": 0, + "electoral_district_id": "franklin-county", + "vote_variation": "one_of_m", "votes_allowed": 1, "name": "Referendum for Banning Pineapple on Pizza", "ballot_selections": [ diff --git a/data/manifest-small.json b/data/manifest-small.json index 3b88d371..aac08f1f 100755 --- a/data/manifest-small.json +++ b/data/manifest-small.json @@ -127,7 +127,6 @@ "sequence_order": 0, "electoral_district_id": "franklin-county", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "Representative to US Congress", "ballot_selections": [ @@ -155,7 +154,6 @@ "sequence_order": 1, "electoral_district_id": "franklin-county", "vote_variation": "one_of_m", - "number_elected": 1, "votes_allowed": 1, "name": "The Pineapple Question", "ballot_selections": [ @@ -178,7 +176,6 @@ "sequence_order": 2, "electoral_district_id": "ozark-school-district", "vote_variation": "n_of_m", - "number_elected": 2, "votes_allowed": 2, "name": "Ozark School Board", "ballot_selections": [ diff --git a/data/plaintext_ballots_simple.json b/data/plaintext_ballots_simple.json index 1539b9ce..3e91fd7f 100644 --- a/data/plaintext_ballots_simple.json +++ b/data/plaintext_ballots_simple.json @@ -108,6 +108,19 @@ "vote": 1 } ] + }, + { + "object_id": "official-bagel-style", + "ballot_selections": [ + { + "object_id": "new-york-style-selection", + "vote": 2 + }, + { + "object_id": "st-louis-style-selection", + "vote": 1 + } + ] } ] }, @@ -140,6 +153,19 @@ "vote": 1 } ] + }, + { + "object_id": "official-bagel-style", + "ballot_selections": [ + { + "object_id": "new-york-style-selection", + "vote": 1 + }, + { + "object_id": "montreal-style-selection", + "vote": 0 + } + ] } ] } diff --git a/src/electionguard/__init__.py b/src/electionguard/__init__.py index 93cbdadd..6c6a5213 100644 --- a/src/electionguard/__init__.py +++ b/src/electionguard/__init__.py @@ -100,11 +100,13 @@ ChaumPedersenProof, ConstantChaumPedersenProof, DisjunctiveChaumPedersenProof, + RangeChaumPedersenProof, make_chaum_pedersen, make_constant_chaum_pedersen, make_disjunctive_chaum_pedersen, make_disjunctive_chaum_pedersen_one, make_disjunctive_chaum_pedersen_zero, + make_range_chaum_pedersen, ) from electionguard.constants import ( EXTRA_SMALL_TEST_CONSTANTS, @@ -318,7 +320,6 @@ CandidateContestDescription, ContactInformation, ContestDescription, - ContestDescriptionWithPlaceholders, ElectionType, GeopoliticalUnit, InternalManifest, @@ -332,9 +333,6 @@ SelectionDescription, SpecVersion, VoteVariationType, - contest_description_with_placeholders_from, - generate_placeholder_selection_from, - generate_placeholder_selections_from, get_i8n_value, ) from electionguard.nonces import ( @@ -443,7 +441,6 @@ "ContactInformation", "ContestData", "ContestDescription", - "ContestDescriptionWithPlaceholders", "ContestErrorType", "ContestException", "ContestId", @@ -521,6 +518,7 @@ "ProofUsage", "PublicCommitment", "PublishedCiphertextTally", + "RangeChaumPedersenProof", "ReadOnlyDataStore", "RecoveryPublicKey", "ReferendumContestDescription", @@ -578,7 +576,6 @@ "compute_recovery_public_key", "constants", "construct_path", - "contest_description_with_placeholders_from", "contest_from", "contest_is_valid_for_style", "create_ballot_hash", @@ -633,8 +630,6 @@ "generate_election_key_pair", "generate_election_partial_key_backup", "generate_election_partial_key_challenge", - "generate_placeholder_selection_from", - "generate_placeholder_selections_from", "generate_polynomial", "get_backup_seed", "get_ballot_code", @@ -687,6 +682,7 @@ "make_disjunctive_chaum_pedersen", "make_disjunctive_chaum_pedersen_one", "make_disjunctive_chaum_pedersen_zero", + "make_range_chaum_pedersen", "make_schnorr_proof", "manifest", "match_optional", diff --git a/src/electionguard/ballot.py b/src/electionguard/ballot.py index 8aab717c..016d8d92 100644 --- a/src/electionguard/ballot.py +++ b/src/electionguard/ballot.py @@ -15,10 +15,8 @@ from .ballot_code import get_ballot_code from .chaum_pedersen import ( - ConstantChaumPedersenProof, - DisjunctiveChaumPedersenProof, - make_constant_chaum_pedersen, - make_disjunctive_chaum_pedersen, + RangeChaumPedersenProof, + make_range_chaum_pedersen, ) from .election_object_base import ( ElectionObjectBase, @@ -50,28 +48,19 @@ @dataclass(unsafe_hash=True) class PlaintextBallotSelection(ElectionObjectBase): """ - A BallotSelection represents an individual selection on a ballot. + Represents an unencrypted, individual selection on a ballot. - This class accepts a `vote` integer field which has no constraints - in the ElectionGuard Data Specification, but is constrained logically - in the application to resolve to `False` or `True` aka only 0 and 1 is - supported for now. + This class accepts a `vote` integer field which has no constraints. + The range Chaum-Pedersen proofs now support encodings of any nonnegative integer, + not just the binary 0 or 1 as previously supported. - This class can also be designated as `is_placeholder_selection` which has no - context to the data specification but is useful for running validity checks internally - - Write_in field exists to support the cleartext representation of a write-in candidate value. + The `write_in` field exists to support cleartext representation of a write-in candidate value. """ vote: int - is_placeholder_selection: bool = field(default=False) - """Determines if this is a placeholder selection""" - write_in: Optional[str] = field(default=None) - """ - Write_in field exists to support the cleartext representation of a write-in candidate value. - """ + """Cleartext representation of a write-in candidate value""" def is_valid(self, expected_object_id: str) -> bool: """ @@ -86,8 +75,8 @@ def is_valid(self, expected_object_id: str) -> bool: return False vote = self.vote - if vote < 0 or vote > 1: - log_warning(f"Currently only supporting choices of 0 or 1: {str(self)}") + if vote < 0: + log_warning(f"Currently only supporting nonnegative choices: {str(self)}") return False return True @@ -97,7 +86,6 @@ def __eq__(self, other: Any) -> bool: isinstance(other, PlaintextBallotSelection) and self.object_id == other.object_id and self.vote == other.vote - and self.is_placeholder_selection == other.is_placeholder_selection and self.write_in == other.write_in ) @@ -114,7 +102,7 @@ class CiphertextSelection(Protocol): object_id: str sequence_order: int - """Order the selection.""" + """Order the selection""" description_hash: ElementModQ """The SelectionDescription hash""" @@ -134,21 +122,21 @@ class CiphertextBallotSelection( in its constructor. When a selection is encrypted, the `description_hash` and `ciphertext` required fields must - be populated at construction however the `nonce` is also usually provided by convention. + be populated at construction; however, the `nonce` is also usually provided by convention. - After construction, the `crypto_hash` field is populated automatically in the `__post_init__` cycle + After construction, the `crypto_hash` field is populated automatically in the `__post_init__` cycle. A consumer of this object has the option to discard the `nonce` and/or discard the `proof`, or keep both values. By discarding the `nonce`, the encrypted representation and `proof` can only be regenerated if the nonce was derived from the ballot's master nonce. If the nonce - used for this selection is truly random, and it is discarded, then the proofs cannot be regenerated. + used for this selection is truly random and is discarded, then the proofs cannot be regenerated. - By keeping the `nonce`, or deriving the selection nonce from the ballot nonce, an external system can - regenerate the proofs on demand. This is useful for storage or memory constrained systems. + By keeping the `nonce` or deriving the selection nonce from the ballot nonce, an external system can + regenerate the proofs on demand. This is useful for storage- or memory-constrained systems. - By keeping the `proof` the nonce is not required fotor verify the encrypted selection. + By keeping the `proof`, verifying the encryption does not require the `nonce`. """ description_hash: ElementModQ @@ -160,14 +148,14 @@ class CiphertextBallotSelection( crypto_hash: ElementModQ """The hash of the encrypted values""" - is_placeholder_selection: bool = field(default=False) - """Determines if this is a placeholder selection""" - nonce: Optional[ElementModQ] = field(default=None) """The nonce used to generate the encryption. Sensitive & should be treated as a secret""" - proof: Optional[DisjunctiveChaumPedersenProof] = field(default=None) - """The proof that demonstrates the selection is an encryption of 0 or 1, and was encrypted using the `nonce`""" + proof: Optional[RangeChaumPedersenProof] = field(default=None) + """The proof that the selection used the `nonce` to encrypt an integer in a certain range""" + + selection_limit: int = 1 + """Maximum number of votes allowed for the selection""" def is_valid_encryption( self, @@ -176,11 +164,11 @@ def is_valid_encryption( crypto_extended_base_hash: ElementModQ, ) -> bool: """ - Given an encrypted BallotSelection, validates the encryption state against a specific seed and public key. - Calling this function expects that the object is in a well-formed encrypted state - with the elgamal encrypted `message` field populated along with - the DisjunctiveChaumPedersenProof`proof` populated. - the ElementModQ `description_hash` and the ElementModQ `crypto_hash` are also checked. + Given an encrypted ballot selection, validates the encryption state against a specific + seed and public key. Calling this function expects that the object is in a well-formed + encrypted state with the ElGamal encrypted `message` field populated along with the + RangeChaumPedersenProof `proof` populated. + The ElementModQ `description_hash` and the ElementModQ `crypto_hash` are also checked. :param encryption_seed: the hash of the SelectionDescription, or whatever `ElementModQ` was used to populate the `description_hash` field. @@ -209,6 +197,14 @@ def is_valid_encryption( if self.proof is None: log_warning(f"no proof exists for: {self.object_id}") return False + if self.proof.limit != self.selection_limit: + log_warning( + ( + f"mismatching range proof limit: {self.object_id} expected {str(self.selection_limit)}, " + f"actual({str(self.proof.limit)})" + ) + ) + return False return self.proof.is_valid( self.ciphertext, elgamal_public_key, crypto_extended_base_hash @@ -216,11 +212,11 @@ def is_valid_encryption( def crypto_hash_with(self, encryption_seed: ElementModQ) -> ElementModQ: """ - Given an encrypted BallotSelection, generates a hash, suitable for rolling up + Given an encrypted ballot selection, generates a hash suitable for rolling up into a hash for an entire ballot / ballot code. Of note, this particular hash examines the `encryption_seed` and `message`, but not the proof. This is deliberate, allowing for the possibility of ElectionGuard variants running on - much more limited hardware, wherein the Disjunctive Chaum-Pedersen proofs might be computed + much more limited hardware, wherein the range Chaum-Pedersen proofs might be computed later on. In most cases the encryption_seed should match the `description_hash` @@ -245,14 +241,14 @@ def make_ciphertext_ballot_selection( crypto_extended_base_hash: ElementModQ, proof_seed: ElementModQ, selection_representation: int, - is_placeholder_selection: bool = False, + selection_limit: int, nonce: Optional[ElementModQ] = None, crypto_hash: Optional[ElementModQ] = None, - proof: Optional[DisjunctiveChaumPedersenProof] = None, + proof: Optional[RangeChaumPedersenProof] = None, ) -> CiphertextBallotSelection: """ Constructs a `CipherTextBallotSelection` object. Most of the parameters here match up to fields - in the class, but this helper function will optionally compute a Chaum-Pedersen proof if the + in the class, but this helper function will optionally compute a range Chaum-Pedersen proof if the given nonce isn't `None`. Likewise, if a crypto_hash is not provided, it will be derived from the other fields. """ @@ -264,13 +260,14 @@ def make_ciphertext_ballot_selection( if proof is None: proof = flatmap_optional( nonce, - lambda n: make_disjunctive_chaum_pedersen( + lambda n: make_range_chaum_pedersen( ciphertext, n, elgamal_public_key, crypto_extended_base_hash, proof_seed, - selection_representation, + plaintext=selection_representation, + limit=selection_limit, ), ) @@ -280,9 +277,9 @@ def make_ciphertext_ballot_selection( description_hash, ciphertext, crypto_hash, - is_placeholder_selection, nonce, proof, + selection_limit, ) @@ -291,14 +288,13 @@ class PlaintextBallotContest(ElectionObjectBase): """ A PlaintextBallotContest represents the selections made by a voter for a specific ContestDescription - this class can be either a partial or a complete representation of a contest dataset. Specifically, + This class can be either a partial or a complete representation of a contest dataset. Specifically, a partial representation must include at a minimum the "affirmative" selections of a contest. A complete representation of a ballot must include both affirmative and negative selections of - the contest, AND the placeholder selections necessary to satisfy the ConstantChaumPedersen proof - in the CiphertextBallotContest. + the contest. With the range Chaum-Pedersen proofs, placeholder selections are no longer needed. - Typically partial contests are passed into Electionguard for memory constrained systems, - while complete contests are passed into ElectionGuard when running encryption on an existing dataset. + Typically, ElectionGuard operates with partial contests for memory-constrained systems, + while complete contests are used when encrypting existing datasets. """ ballot_selections: List[PlaintextBallotSelection] = field( @@ -316,12 +312,8 @@ def selected_ids(self) -> List[SelectionId]: @cached_property def total_selected(self) -> int: - """Returns the total number of selected selections.""" - return reduce( - lambda prev, next: prev + (1 if next.vote > 0 else 0), - self.ballot_selections, - 0, - ) + """Returns the total number of selections with positive vote.""" + return len(self.selected_ids) @cached_property def total_votes(self) -> int: @@ -351,7 +343,7 @@ def valid(self, description: ContestDescription) -> None: override_message=f"invalid format of contest or description for contest {self.object_id}", ) - # Selections ids match description + # Selection ids match description selection_ids = { selection.object_id for selection in description.ballot_selections } @@ -363,38 +355,26 @@ def valid(self, description: ContestDescription) -> None: ) # Specialty cases - if self.total_selected < 1: + if self.total_votes == 0: raise NullVoteException(self.object_id) - if self.total_selected < description.number_elected: + if self.total_votes < description.votes_allowed: raise UnderVoteException(self.object_id) - if self.total_selected > description.number_elected: + if self.total_votes > description.votes_allowed: raise OverVoteException(self.object_id, self.selected_ids) - if description.votes_allowed is not None: - if self.total_votes > description.votes_allowed: - raise OverVoteException(self.object_id, self.selected_ids) - - # Support for other cases such as cumulative voting not currently supported. - # (individual selections being an encryption of > 1) - if self.total_selected < description.votes_allowed: - raise ContestException( - self.object_id, - override_message=f"`on contest {self.object_id}: only n-of-m style elections are supported", - ) - def is_valid( self, expected_object_id: str, expected_number_selections: int, - expected_number_elected: int, - votes_allowed: Optional[int] = None, + votes_allowed: int = 1, + votes_allowed_per_selection: int = 1, ) -> bool: """ - Given a PlaintextBallotContest returns true if the state is representative of the expected values. + Returns whether the PlaintextBallotContest state represents the expected values. - Note: because this class supports partial representations, undervotes are considered a valid state. + Note: Due to partial representation support, an undervote is a valid state. """ if self.object_id != expected_object_id: @@ -415,25 +395,23 @@ def is_valid( ) return False - number_elected = 0 - votes = 0 - - # Verify the selections are well-formed + num_votes = 0 + # Verify that each selection is well-formed for selection in self.ballot_selections: - votes += selection.vote - if selection.vote >= 1: - number_elected += 1 + if selection.vote > votes_allowed_per_selection: + log_warning( + f"invalid selection.vote: expected(<={votes_allowed_per_selection}) " + f"actual ({selection.vote})" + ) + return False + num_votes += selection.vote - if number_elected > expected_number_elected: + if num_votes > votes_allowed: log_warning( - f"invalid number_elected: expected({expected_number_elected}) actual({number_elected})" + f"invalid num_votes: expected({votes_allowed}) actual({num_votes})" ) return False - if votes_allowed is not None and votes > votes_allowed: - log_warning(f"invalid votes: expected({votes_allowed}) actual({votes})") - return False - return True def __eq__(self, other: Any) -> bool: @@ -458,6 +436,7 @@ class CiphertextContest(OrderedObjectBase): """Collection of selections""" +# pylint: disable=too-many-instance-attributes @dataclass(unsafe_hash=True) class CiphertextBallotContest(OrderedObjectBase, CryptoHashCheckable): """ @@ -466,17 +445,16 @@ class CiphertextBallotContest(OrderedObjectBase, CryptoHashCheckable): CiphertextBallotContest can only be a complete representation of a contest dataset. While PlaintextBallotContest supports a partial representation, a CiphertextBallotContest includes all data necessary for a verifier to verify the contest. Specifically, it includes both explicit affirmative - and negative selections of the contest, as well as the placeholder selections that satisfy - the ConstantChaumPedersen proof. + and negative selections of the contest. Similar to `CiphertextBallotSelection` the consuming application can choose to discard or keep both - the `nonce` and the `proof` in some circumstances. For deterministic nonce's derived from the + the `nonce` and the `proof` in some circumstances. For deterministic nonces derived from the master nonce, both values can be regenerated. If the `nonce` for this contest is completely random, then it is required in order to regenerate the proof. """ description_hash: ElementModQ - """Hash from contestDescription""" + """Hash from ContestDescription""" ballot_selections: List[CiphertextBallotSelection] """Collection of ballot selections""" @@ -487,17 +465,20 @@ class CiphertextBallotContest(OrderedObjectBase, CryptoHashCheckable): crypto_hash: ElementModQ """Hash of the encrypted values""" + contest_limit: int + """The count of allotted votes for the contest""" + nonce: Optional[ElementModQ] = None """The nonce used to generate the encryption. Sensitive & should be treated as a secret""" - proof: Optional[ConstantChaumPedersenProof] = None + proof: Optional[RangeChaumPedersenProof] = None """ - The proof demonstrates the sum of the selections does not exceed the maximum - available selections for the contest, and that the proof was generated with the nonce + The proof demonstrates that the sum of selections does not exceed the count of allotted votes + for the contest and that the proof was generated with the `nonce` """ extended_data: Optional[HashedElGamalCiphertext] = field(default=None) - """encrypted representation of the extended_data field""" + """Encrypted representation of the extended_data field""" def __eq__(self, other: Any) -> bool: return ( @@ -527,7 +508,7 @@ def crypto_hash_with(self, encryption_seed: ElementModQ) -> ElementModQ: into a hash for an entire ballot / ballot code. Of note, this particular hash examines the `encryption_seed` and `ballot_selections`, but not the proof. This is deliberate, allowing for the possibility of ElectionGuard variants running on - much more limited hardware, wherein the Disjunctive Chaum-Pedersen proofs might be computed + much more limited hardware, wherein the Chaum-Pedersen proofs might be computed later on. In most cases, the encryption_seed is the description_hash @@ -538,7 +519,7 @@ def crypto_hash_with(self, encryption_seed: ElementModQ) -> ElementModQ: def elgamal_accumulate(self) -> ElGamalCiphertext: """ - Add the individual ballot_selections `message` fields together, suitable for use + Add the individual ballot_selections' `message` fields together, suitable for use in a Chaum-Pedersen proof. """ return _ciphertext_ballot_elgamal_accumulate(self.ballot_selections) @@ -550,13 +531,13 @@ def is_valid_encryption( crypto_extended_base_hash: ElementModQ, ) -> bool: """ - Given an encrypted BallotContest, validates the encryption state against a specific seed and public key - by verifying the accumulated sum of selections match the proof. + Given an encrypted BallotContest, validates the encryption state against a specific seed + and public key by verifying that the accumulated sum of selections matches the proof. Calling this function expects that the object is in a well-formed encrypted state - with the `ballot_selections` populated with valid encrypted ballot selections, - the ElementModQ `description_hash`, the ElementModQ `crypto_hash`, - and the ConstantChaumPedersenProof all populated. - Specifically, the seed in this context is the hash of the ContestDescription, + with the `ballot_selections` populated with valid encrypted ballot selections and + that the ElementModQ `description_hash`, ElementModQ `crypto_hash`, + and range Chaum-Pedersen proof are all populated. + Specifically, the seed in this context is the hash of the ContestDescription or whatever `ElementModQ` was used to populate the `description_hash` field. """ if encryption_seed != self.description_hash: @@ -578,11 +559,19 @@ def is_valid_encryption( ) return False - # NOTE: this check does not verify the proofs of the individual selections by design. - + # NOTE: By design, this check does not verify the proof of each individual selection + # This only ensures that the proof exists for the contest sum if self.proof is None: log_warning(f"no proof exists for: {self.object_id}") return False + if self.proof.limit != self.contest_limit: + log_warning( + ( + f"mismatching range proof limit: {self.object_id} expected {str(self.contest_limit)}, " + f"actual({str(self.proof.limit)})" + ) + ) + return False computed_ciphertext_accumulation = self.elgamal_accumulate() @@ -648,9 +637,10 @@ def make_ciphertext_ballot_contest( elgamal_public_key: ElGamalPublicKey, crypto_extended_base_hash: ElementModQ, proof_seed: ElementModQ, - number_elected: int, + number_votes: int, + votes_allowed: int, crypto_hash: Optional[ElementModQ] = None, - proof: Optional[ConstantChaumPedersenProof] = None, + proof: Optional[RangeChaumPedersenProof] = None, nonce: Optional[ElementModQ] = None, extended_data: Optional[HashedElGamalCiphertext] = None, ) -> CiphertextBallotContest: @@ -670,13 +660,14 @@ def make_ciphertext_ballot_contest( if proof is None: proof = flatmap_optional( aggregate, - lambda ag: make_constant_chaum_pedersen( + lambda ag: make_range_chaum_pedersen( elgamal_accumulation, - number_elected, ag, elgamal_public_key, - proof_seed, crypto_extended_base_hash, + proof_seed, + plaintext=number_votes, + limit=votes_allowed, ), ) return CiphertextBallotContest( @@ -686,6 +677,7 @@ def make_ciphertext_ballot_contest( ballot_selections, elgamal_accumulation, crypto_hash, + votes_allowed, nonce, proof, extended_data, @@ -695,7 +687,7 @@ def make_ciphertext_ballot_contest( @dataclass(unsafe_hash=True) class PlaintextBallot(ElectionObjectBase): """ - A PlaintextBallot represents a voters selections for a given ballot and ballot style + A PlaintextBallot represents a voter's selections for a given ballot and ballot style :field object_id: A unique Ballot ID that is relevant to the external system """ @@ -737,13 +729,13 @@ def __ne__(self, other: Any) -> bool: @dataclass(unsafe_hash=True) class CiphertextBallot(ElectionObjectBase, CryptoHashCheckable): """ - A CiphertextBallot represents a voters encrypted selections for a given ballot and ballot style. + A CiphertextBallot represents a voter's encrypted selections for a given ballot and ballot style. - When a ballot is in it's complete, encrypted state, the `nonce` is the master nonce - from which all other nonces can be derived to encrypt the ballot. Allong with the `nonce` - fields on `Ballotcontest` and `BallotSelection`, this value is sensitive. + When a ballot is in its complete, encrypted state, the `nonce` is the master nonce + from which all other nonces can be derived to encrypt the ballot. Along with the `nonce` + fields on `BallotContest` and `PlaintextBallotSelection`, this value is sensitive. - Don't make this directly. Use `make_ciphertext_ballot` instead. + Do not directly construct this; use `make_ciphertext_ballot` instead. :field object_id: A unique Ballot ID that is relevant to the external system """ @@ -793,7 +785,7 @@ def nonce_seed( manifest_hash: ElementModQ, object_id: str, nonce: ElementModQ ) -> ElementModQ: """ - :return: a representation of the election and the external Id in the nonce's used + :return: a representation of the election, the external Id, and the nonce used to derive other nonce values on the ballot """ return hash_elems(manifest_hash, object_id, nonce) @@ -818,7 +810,7 @@ def crypto_hash_with(self, encryption_seed: ElementModQ) -> ElementModQ: into a hash for an entire ballot / ballot code. Of note, this particular hash examines the `manifest_hash` and `ballot_selections`, but not the proof. This is deliberate, allowing for the possibility of ElectionGuard variants running on - much more limited hardware, wherein the Disjunctive Chaum-Pedersen proofs might be computed + much more limited hardware, wherein the Chaum-Pedersen proofs might be computed later on. """ if len(self.contests) == 0: @@ -838,11 +830,11 @@ def is_valid_encryption( ) -> bool: """ Given an encrypted Ballot, validates the encryption state against a specific seed and public key - by verifying the states of this ballot's children (BallotContest's and BallotSelection's). + by verifying the states of this ballot's children (BallotContests and BallotSelections). Calling this function expects that the object is in a well-formed encrypted state - with the `contests` populated with valid encrypted ballot selections, + with the `contests` populated with valid encrypted ballot selections and the ElementModQ `manifest_hash` also populated. - Specifically, the seed in this context is the hash of the Election Manifest, + Specifically, the seed in this context is the hash of the election manifest or whatever `ElementModQ` was used to populate the `manifest_hash` field. """ @@ -943,7 +935,7 @@ def make_ciphertext_ballot( ballot_code: Optional[ElementModQ] = None, ) -> CiphertextBallot: """ - Makes a `CiphertextBallot`, initially in the state where it's neither been cast nor spoiled. + Makes a `CiphertextBallot`, initially in the unknown state: neither cast nor spoiled. :param object_id: the object_id of this specific ballot :param style_id: The `object_id` of the `BallotStyle` in the `Election` Manifest diff --git a/src/electionguard/ballot_compact.py b/src/electionguard/ballot_compact.py index 96e4d2be..fc21b085 100644 --- a/src/electionguard/ballot_compact.py +++ b/src/electionguard/ballot_compact.py @@ -16,7 +16,7 @@ from .encrypt import encrypt_ballot_contests from .group import ElementModQ from .manifest import ( - ContestDescriptionWithPlaceholders, + ContestDescription, InternalManifest, ) from .utils import get_optional @@ -144,24 +144,17 @@ def _get_plaintext_contests( ) -> List[PlaintextBallotContest]: """Get ballot contests from compact plaintext ballot""" index = 0 - ballot_style_contests = _get_ballot_style_contests( - compact_ballot.style_id, internal_manifest - ) contests: List[PlaintextBallotContest] = [] for manifest_contest in sequence_order_sort(internal_manifest.contests): - contest_in_style = ( - ballot_style_contests.get(manifest_contest.object_id) is not None - ) - # Iterate through selections. If contest not in style, mark placeholder + # Iterate through selections selections: List[PlaintextBallotSelection] = [] for selection in sequence_order_sort(manifest_contest.ballot_selections): selections.append( PlaintextBallotSelection( selection.object_id, YES_VOTE if compact_ballot.selections[index] else NO_VOTE, - not contest_in_style, compact_ballot.write_ins.get(index), ) ) @@ -173,6 +166,6 @@ def _get_plaintext_contests( def _get_ballot_style_contests( ballot_style_id: str, internal_manifest: InternalManifest -) -> Dict[str, ContestDescriptionWithPlaceholders]: +) -> Dict[str, ContestDescription]: ballot_style_contests = internal_manifest.get_contests_for(ballot_style_id) return {contest.object_id: contest for contest in ballot_style_contests} diff --git a/src/electionguard/ballot_validator.py b/src/electionguard/ballot_validator.py index a928dabd..72fbf587 100644 --- a/src/electionguard/ballot_validator.py +++ b/src/electionguard/ballot_validator.py @@ -2,7 +2,7 @@ from .election import CiphertextElectionContext from .logs import log_warning from .manifest import ( - ContestDescriptionWithPlaceholders, + ContestDescription, InternalManifest, SelectionDescription, ) @@ -57,7 +57,7 @@ def selection_is_valid_for_style( def contest_is_valid_for_style( - contest: CiphertextBallotContest, description: ContestDescriptionWithPlaceholders + contest: CiphertextBallotContest, description: ContestDescription ) -> bool: """ Determine if contest is valid for ballot style @@ -75,10 +75,8 @@ def contest_is_valid_for_style( ) return False - # verify the placeholder count - if len(contest.ballot_selections) != len(description.ballot_selections) + len( - description.placeholder_selections - ): + # verify the selection count + if len(contest.ballot_selections) > len(description.ballot_selections): log_warning( f"ballot is not valid for style: mismatched selection count for contest {description.object_id}" ) diff --git a/src/electionguard/chaum_pedersen.py b/src/electionguard/chaum_pedersen.py index 569648df..3802f62e 100644 --- a/src/electionguard/chaum_pedersen.py +++ b/src/electionguard/chaum_pedersen.py @@ -1,5 +1,6 @@ # pylint: disable=too-many-instance-attributes from dataclasses import dataclass +from typing import List from .elgamal import ElGamalCiphertext from .group import ( @@ -12,8 +13,10 @@ a_plus_bc_q, add_q, negate_q, + mult_q, int_to_q, ZERO_MOD_Q, + ZERO_MOD_P, ) from .hash import hash_elems from .logs import log_warning @@ -21,10 +24,138 @@ from .proof import Proof, ProofUsage +@dataclass +class RangeChaumPedersenProof(Proof): + """ + Representation of range Chaum-Pedersen proof + """ + + commitments: List[ElementModP] + """[a0, b0, a1, b1, ..., aL, bL]""" + + challenges: List[ElementModQ] + """[c0, c1, ..., cL, c]""" + + responses: List[ElementModQ] + """[v0, v1, ..., vL]""" + + limit: int = -1 + """L""" + + usage: ProofUsage = ProofUsage.RangeValue + """A description of how to use this proof""" + + def __post_init__(self) -> None: + super().__init__() + if self.limit < 0: + self.limit = len(self.responses) - 1 + + def is_valid( + self, + message: ElGamalCiphertext, + k: ElementModP, + q: ElementModQ, + ) -> bool: + """ + Validates a range Chaum-Pedersen proof. + + :param message: The ciphertext message + :param k: The public encryption key + :param q: The extended base hash of the election + :return: Whether the proof is valid + """ + alpha = message.pad + beta = message.data + commitments = self.commitments + challenges = self.challenges + responses = self.responses + limit = self.limit + + assert ( + len(commitments) == 2 * (limit + 1) + and limit == len(responses) - 1 == len(challenges) - 2 + ), ( + "RangeChaumPedersenProof.is_valid only supports proofs with a commitment, challenge," + + " and response for each possible integer value, plus one additional challenge." + ) + + # (4.A) + valid_residue_alpha = alpha.is_valid_residue() + valid_residue_beta = beta.is_valid_residue() + valid_residue_pads = [ + commitments[2 * j].is_valid_residue() for j in range(limit + 1) + ] + valid_residue_data = [ + commitments[2 * j + 1].is_valid_residue() for j in range(limit + 1) + ] + + # (4.B) + c = challenges[-1] + consistent_c_hash = c == hash_elems(q, alpha, beta, *commitments) + + # (4.C) + in_bounds_challenges = [cj.is_in_bounds() for cj in challenges[:-1]] + in_bounds_responses = [vj.is_in_bounds() for vj in responses] + + # (4.D) + consistent_c_sum = c == add_q(*challenges[:-1]) + + # (4.E'): check pad equations + consistent_pads = [ + g_pow_p(vj) == mult_p(commitments[2 * j], pow_p(alpha, challenges[j])) + for j, vj in enumerate(responses) + ] + + # (4.F'): check data equations + consistent_data = [False] * (limit + 1) + consistent_data[0] = pow_p(k, responses[0]) == mult_p( + commitments[1], pow_p(beta, challenges[0]) + ) + for j in range(1, limit + 1): + consistent_data[j] = mult_p( + g_pow_p(mult_q(j, challenges[j])), pow_p(k, responses[j]) + ) == mult_p(commitments[2 * j + 1], pow_p(beta, challenges[j])) + + success = ( + valid_residue_alpha + and valid_residue_beta + and all(valid_residue_pads) + and all(valid_residue_data) + and consistent_c_hash + and all(in_bounds_challenges) + and all(in_bounds_responses) + and consistent_c_sum + and all(consistent_pads) + and all(consistent_data) + ) + if not success: + log_warning( + "found an invalid range Chaum-Pedersen proof: " + + str( + { + "valid_residue_alpha": valid_residue_alpha, + "valid_residue_beta": valid_residue_beta, + "valid_residue_pads": valid_residue_pads, + "valid_residue_data": valid_residue_data, + "consistent_c_hash": consistent_c_hash, + "in_bounds_challenges": in_bounds_challenges, + "in_bounds_responses": in_bounds_responses, + "consistent_c_sum": consistent_c_sum, + "consistent_pads": consistent_pads, + "consistent_data": consistent_data, + "k": k, + "limit": limit, + "proof": self, + } + ) + ) + return success + + @dataclass class DisjunctiveChaumPedersenProof(Proof): """ - Representation of disjunctive Chaum Pederson proof + Representation of disjunctive Chaum-Pedersen proof """ proof_zero_pad: ElementModP @@ -45,7 +176,7 @@ class DisjunctiveChaumPedersenProof(Proof): """proof_zero_response in the spec""" proof_one_response: ElementModQ """proof_one_response in the spec""" - usage: ProofUsage = ProofUsage.SelectionValue + usage: ProofUsage = ProofUsage.BinaryValue """a description of how to use this proof""" def __post_init__(self) -> None: @@ -60,7 +191,7 @@ def is_valid( :param message: The ciphertext message :param k: The public key of the election :param q: The extended base hash of the election - :return: True if everything is consistent. False otherwise. + :return: Whether the proof is valid """ alpha = message.pad @@ -141,7 +272,7 @@ def is_valid( @dataclass class ChaumPedersenProof(Proof): """ - Representation of a generic Chaum-Pedersen Zero Knowledge proof + Representation of a generic Chaum-Pedersen zero-knowledge proof """ pad: ElementModP @@ -166,19 +297,18 @@ def is_valid( q: ElementModQ, ) -> bool: """ - Validates a Chaum-Pedersen proof. - e.g. - - The given value 𝑣𝑖 is in the set Zπ‘ž - - The given values π‘Žπ‘– and 𝑏𝑖 are both in the set Zπ‘ž^π‘Ÿ - - The challenge value 𝑐 satisfies 𝑐 = 𝐻(𝑄, (𝐴, 𝐡), (π‘Ž , 𝑏 ), 𝑀 ). - - that the equations 𝑔^𝑣𝑖 = π‘Žπ‘–πΎ^𝑐𝑖 mod 𝑝 and 𝐴^𝑣𝑖 = 𝑏𝑖𝑀𝑖^𝑐𝑖 mod 𝑝 are satisfied. + Validates a Chaum-Pedersen proof (see Verification 8 in v1.1 of the specification). + That is, verify the following: + - The response v is in the valid range of Z_q + - The commitments a and b are valid residues (i.e., in Z_p^r) + - The challenge c is the hash value H(Q', A, B, a, b, M) + - The equations g^v mod p = a*K^c mod p and A^v mod p = b*M^c mod p hold. :param message: The ciphertext message - :param k: The public key corresponding to the private key used to encrypt - (e.g. the Guardian public election key) - :param m: The value being checked for validity + :param k: The public encryption key (K) + :param m: The value being checked for validity (e.g., decryption share M) :param q: The extended base hash of the election - :return: True if everything is consistent. False otherwise. + :return: Whether the proof is valid """ alpha = message.pad beta = message.data @@ -197,22 +327,10 @@ def is_valid( in_bounds_q = q.is_in_bounds() same_c = c == hash_elems(q, alpha, beta, a, b, m) - consistent_gv = ( - in_bounds_v - and in_bounds_a - and in_bounds_c - # The equation 𝑔^𝑣𝑖 = π‘Žπ‘–πΎ^𝑐𝑖 - and g_pow_p(v) == mult_p(a, pow_p(k, c)) - ) - - # The equation 𝐴^𝑣𝑖 = 𝑏𝑖𝑀𝑖^𝑐𝑖 mod 𝑝 - consistent_av = ( - in_bounds_alpha - and in_bounds_b - and in_bounds_c - and in_bounds_v - and pow_p(alpha, v) == mult_p(b, pow_p(m, c)) - ) + # g^v mod p = a*K^c mod p + consistent_gv = g_pow_p(v) == mult_p(a, pow_p(k, c)) + # A^v mod p = b*M^c mod p + consistent_av = pow_p(alpha, v) == mult_p(b, pow_p(m, c)) success = ( in_bounds_alpha @@ -258,20 +376,20 @@ def is_valid( @dataclass class ConstantChaumPedersenProof(Proof): """ - Representation of constant Chaum Pederson proof + Representation of a constant Chaum-Pedersen proof """ pad: ElementModP """a in the spec""" data: ElementModP - "b in the spec" + """b in the spec""" challenge: ElementModQ - "c in the spec" + """c in the spec""" response: ElementModQ - "v in the spec" + """v in the spec""" constant: int - """constant value""" - usage: ProofUsage = ProofUsage.SelectionLimit + """constant value, L in the spec""" + usage: ProofUsage = ProofUsage.ConstantValue """a description of how to use this proof""" def __post_init__(self) -> None: @@ -281,13 +399,17 @@ def is_valid( self, message: ElGamalCiphertext, k: ElementModP, q: ElementModQ ) -> bool: """ - Validates a "constant" Chaum-Pedersen proof. - e.g. that the equations 𝑔𝑉 = π‘Žπ΄πΆ mod 𝑝 and 𝑔𝐿𝐾𝑣 = 𝑏𝐡𝐢 mod 𝑝 are satisfied. + Validates a constant Chaum-Pedersen proof (see Verification 5 in v1.1 of the specification). + That is, verify the following: + - The response V is in the valid range of Z_q + - The commitments a and b are valid residues (i.e., in Z_p^r) + - The challenge C is the hash value H(Q', Ξ±, Ξ², a, b) + - The equations g^v mod p = a*Ξ±^C mod p and g^(L*C)*K^V mod p = b*Ξ²^c mod p hold. :param message: The ciphertext message - :param k: The public key of the election + :param k: The public encryption key (K) :param q: The extended base hash of the election - :return: True if everything is consistent. False otherwise. + :return: Whether the proof is valid """ alpha = message.pad @@ -315,19 +437,13 @@ def is_valid( # in some use cases this value may need to be increased sane_constant = 0 <= constant < 1_000_000_000 same_c = c == hash_elems(q, alpha, beta, a, b) - consistent_gv = ( - in_bounds_v - and in_bounds_a - and in_bounds_alpha - and in_bounds_c - # The equation 𝑔^𝑉 = π‘Žπ΄^𝐢 mod 𝑝 - and g_pow_p(v) == mult_p(a, pow_p(alpha, c)) - ) - # The equation 𝑔^𝐿𝐾^𝑣 = 𝑏𝐡^𝐢 mod 𝑝 - consistent_kv = in_bounds_constant and mult_p( - g_pow_p(mult_p(c, constant_q)), pow_p(k, v) - ) == mult_p(b, pow_p(beta, c)) + # g^V mod p = a*Ξ±^C mod p + consistent_gv = g_pow_p(v) == mult_p(a, pow_p(alpha, c)) + # g^(L*C)*K^V mod p = b*Ξ²^C mod p + consistent_kv = mult_p(g_pow_p(mult_q(c, constant_q)), pow_p(k, v)) == mult_p( + b, pow_p(beta, c) + ) success = ( in_bounds_alpha @@ -367,6 +483,80 @@ def is_valid( return success +def make_range_chaum_pedersen( + message: ElGamalCiphertext, + r: ElementModQ, + k: ElementModP, + q: ElementModQ, + seed: ElementModQ, + plaintext: int, + limit: int = 1, +) -> RangeChaumPedersenProof: + """ + Produce a proof that the message is an encryption of some integer between 0 and the limit, inclusive. + Critically, the proof does not reveal which particular integer is encrypted. + + :param message: An ElGamal ciphertext + :param r: The encryption nonce + :param k: The public encryption key (K) + :param q: A value for generating the challenge hash, usually the extended base hash (Q') + :param seed: A value for generating nonces + :param plaintext: The integer encrypted by the ElGamal ciphertext + :param limit: The upper limit for the range proof; default value is 1 for usual 0 or 1 encryption + """ + assert ( + 0 <= plaintext <= limit + ), "make_range_chaum_pedersen only supports plaintexts between 0 and the limit." + alpha = message.pad + beta = message.data + + # Aggregate nonces + nonces = Nonces(seed, "range-chaum-pedersen-proof")[: 2 * limit + 1] + + # Generate anticipated challenge values (for non-plaintext values) + challenges = [ZERO_MOD_Q] * (limit + 2) + for j in range(plaintext): + challenges[j] = nonces[j] + for j in range(plaintext + 1, limit + 1): + challenges[j] = nonces[j - 1] + # Notice challenges[plaintext] and challenges[-1] remain 0 + + # Make commitments (for every value) + commitments = [ZERO_MOD_P] * (2 * (limit + 1)) + + def construct_commitment_nonzero_power(j: int) -> None: + """ + Auxiliary function to avoid unnecessary g_pow_p(0) calls + """ + uj = nonces[j + limit] + cj = challenges[j] + aj = g_pow_p(uj) + bj = mult_p(g_pow_p(mult_q(a_minus_b_q(j, plaintext), cj)), pow_p(k, uj)) + commitments[2 * j : 2 * j + 2] = aj, bj + + for j in range(plaintext): + construct_commitment_nonzero_power(j) + up = nonces[plaintext + limit] + ap = g_pow_p(up) + bp = pow_p(k, up) + commitments[2 * plaintext : 2 * plaintext + 2] = ap, bp + for j in range(plaintext + 1, limit + 1): + construct_commitment_nonzero_power(j) + + # Compute the remaining challenge values + c = hash_elems(q, alpha, beta, *commitments) + challenges[plaintext] = a_minus_b_q(c, add_q(*challenges)) + challenges[-1] = c + + # Calculate the response (for every value) + responses = [ + add_q(nonces[j + limit], mult_q(challenges[j], r)) for j in range(limit + 1) + ] + + # Present proof + return RangeChaumPedersenProof(commitments, challenges, responses, limit) + + def make_disjunctive_chaum_pedersen( message: ElGamalCiphertext, r: ElementModQ, @@ -376,15 +566,14 @@ def make_disjunctive_chaum_pedersen( plaintext: int, ) -> DisjunctiveChaumPedersenProof: """ - Produce a "disjunctive" proof that an encryption of a given plaintext is either an encrypted zero or one. + Produce a disjunctive proof that an encryption of a given plaintext encrypts either zero or one. This is just a front-end helper for `make_disjunctive_chaum_pedersen_zero` and `make_disjunctive_chaum_pedersen_one`. :param message: An ElGamal ciphertext :param r: The nonce used creating the ElGamal ciphertext - :param k: The ElGamal public key for the election - :param q: A value used when generating the challenge, - usually the election extended base hash (𝑄') + :param k: The public encryption key (K) + :param q: A value for generating the challenge hash, usually the extended base hash (Q') :param seed: Used to generate other random values here :param plaintext: Zero or one """ @@ -405,13 +594,12 @@ def make_disjunctive_chaum_pedersen_zero( seed: ElementModQ, ) -> DisjunctiveChaumPedersenProof: """ - Produces a "disjunctive" proof that an encryption of zero is either an encrypted zero or one. + Produces a disjunctive proof that an encryption of zero is either an encryption of zero or one. :param message: An ElGamal ciphertext :param r: The nonce used creating the ElGamal ciphertext - :param k: The ElGamal public key for the election - :param q: A value used when generating the challenge, - usually the election extended base hash (𝑄') + :param k: The public encryption key (K) + :param q: A value for generating the challenge hash, usually the extended base hash (Q') :param seed: Used to generate other random values here """ alpha = message.pad @@ -441,20 +629,19 @@ def make_disjunctive_chaum_pedersen_one( seed: ElementModQ, ) -> DisjunctiveChaumPedersenProof: """ - Produces a "disjunctive" proof that an encryption of one is either an encrypted zero or one. + Produces a disjunctive proof that an encryption of one is either an encryption of zero or one. :param message: An ElGamal ciphertext :param r: The nonce used creating the ElGamal ciphertext - :param k: The ElGamal public key for the election - :param q: A value used when generating the challenge, - usually the election extended base hash (𝑄') + :param k: The public encryption key (K) + :param q: A value for generating the challenge hash, usually the extended base hash (Q') :param seed: Used to generate other random values here """ alpha = message.pad beta = message.data # Pick three random numbers in Q. - w, v, u1 = Nonces(seed, "disjoint-chaum-pedersen-proof")[0:3] + w, v, u1 = Nonces(seed, "disjunctive-chaum-pedersen-proof")[0:3] # Compute the NIZKP a0 = g_pow_p(v) @@ -473,61 +660,62 @@ def make_disjunctive_chaum_pedersen_one( def make_chaum_pedersen( message: ElGamalCiphertext, s: ElementModQ, - m: ElementModP, + q: ElementModQ, seed: ElementModQ, - hash_header: ElementModQ, + m: ElementModP, ) -> ChaumPedersenProof: """ - Produces a proof that a given value corresponds to a specific encryption. - computes: 𝑀 =𝐴^𝑠𝑖 mod 𝑝 and 𝐾𝑖 = 𝑔^𝑠𝑖 mod 𝑝 + Produces a proof of knowledge to the secret key s such that + M = A^s mod p and K = g^s mod p. + Refer to section 3.5 in v1.1 of the specification. :param message: An ElGamal ciphertext :param s: The nonce or secret used to derive the value - :param m: The value we are trying to prove + :param q: A value for generating the challenge hash, usually the extended base hash (Q') :param seed: Used to generate other random values here - :param hash_header: A value used when generating the challenge, - usually the election extended base hash (𝑄') + :param m: The computed share (M) + """ alpha = message.pad beta = message.data # Pick one random number in Q. - u = Nonces(seed, "constant-chaum-pedersen-proof")[0] - a = g_pow_p(u) # 𝑔^𝑒𝑖 mod 𝑝 - b = pow_p(alpha, u) # 𝐴^𝑒𝑖 mod 𝑝 - c = hash_elems(hash_header, alpha, beta, a, b, m) # sha256(𝑄', A, B, a𝑖, b𝑖, 𝑀𝑖) - v = a_plus_bc_q(u, c, s) # (𝑒𝑖 + 𝑐𝑖𝑠𝑖) mod π‘ž + u = Nonces(seed, "chaum-pedersen-proof")[0] + a = g_pow_p(u) # g^u mod p + b = pow_p(alpha, u) # A^u mod p + c = hash_elems(q, alpha, beta, a, b, m) # H(Q', A, B, a, b, M) + v = a_plus_bc_q(u, c, s) # (u + c*s) mod π‘ž return ChaumPedersenProof(a, b, c, v) def make_constant_chaum_pedersen( message: ElGamalCiphertext, - constant: int, r: ElementModQ, k: ElementModP, + q: ElementModQ, seed: ElementModQ, - hash_header: ElementModQ, + constant: int, ) -> ConstantChaumPedersenProof: """ - Produces a proof that a given encryption corresponds to a specific total value. + Produces a proof that an encryption encodes a particular value. + Refer to section 3.3.5 in v1.1 of the specification. :param message: An ElGamal ciphertext - :param constant: The plaintext constant value used to make the ElGamal ciphertext (L in the spec) :param r: The aggregate nonce used creating the ElGamal ciphertext - :param k: The ElGamal public key for the election + :param k: The public encryption key (K) + :param q: A value for generating the challenge hash, usually the extended base hash (Q') :param seed: Used to generate other random values here - :param hash_header: A value used when generating the challenge, - usually the election extended base hash (𝑄') + :param constant: The plaintext constant value used to make the ElGamal ciphertext (L) """ alpha = message.pad beta = message.data - # Pick one random number in Q. + # Pick one random number in Z_q. u = Nonces(seed, "constant-chaum-pedersen-proof")[0] - a = g_pow_p(u) # 𝑔^𝑒𝑖 mod 𝑝 - b = pow_p(k, u) # 𝐴^𝑒𝑖 mod 𝑝 - c = hash_elems(hash_header, alpha, beta, a, b) # sha256(𝑄', A, B, a, b) - v = a_plus_bc_q(u, c, r) + a = g_pow_p(u) # g^u mod p + b = pow_p(k, u) # K^u mod p + c = hash_elems(q, alpha, beta, a, b) # sha256(Q', Ξ±', Ξ²', a, b) + v = a_plus_bc_q(u, c, r) # (U + C*R) mod q return ConstantChaumPedersenProof(a, b, c, v, constant) diff --git a/src/electionguard/decrypt_with_secrets.py b/src/electionguard/decrypt_with_secrets.py index e37b3376..d2fb7892 100644 --- a/src/electionguard/decrypt_with_secrets.py +++ b/src/electionguard/decrypt_with_secrets.py @@ -13,7 +13,7 @@ from .logs import log_warning from .manifest import ( InternalManifest, - ContestDescriptionWithPlaceholders, + ContestDescription, SelectionDescription, ) from .nonces import Nonces @@ -55,7 +55,6 @@ def decrypt_selection_with_secret( return PlaintextBallotSelection( selection.object_id, plaintext_vote, - selection.is_placeholder_selection, ) @@ -71,7 +70,7 @@ def decrypt_selection_with_nonce( Decrypt the specified `CiphertextBallotSelection` within the context of the specified selection. :param selection: the contest selection to decrypt - :param description: the qualified selection metadata that may be a placeholder selection + :param description: qualified selection metadata :param public_key: the public key for the election (K) :param crypto_extended_base_hash: the extended base hash code (𝑄') for the election :param nonce_seed: the optional nonce that was seeded to the encryption function. @@ -110,29 +109,26 @@ def decrypt_selection_with_nonce( return PlaintextBallotSelection( selection.object_id, plaintext_vote, - selection.is_placeholder_selection, ) def decrypt_contest_with_secret( contest: CiphertextBallotContest, - description: ContestDescriptionWithPlaceholders, + description: ContestDescription, public_key: ElGamalPublicKey, secret_key: ElGamalSecretKey, crypto_extended_base_hash: ElementModQ, suppress_validity_check: bool = False, - remove_placeholders: bool = True, ) -> Optional[PlaintextBallotContest]: """ Decrypt the specified `CiphertextBallotContest` within the context of the specified contest. :param contest: the contest to decrypt - :param description: the qualified contest metadata that includes placeholder selections + :param description: qualified contest metadata :param public_key: the public key for the election (K) :param secret_key: the known secret key used to generate the public key for this election - :param crypto_extended_base_hash: the extended base hash code (𝑄') for the election + :param crypto_extended_base_hash: the extended base hash code (Q') for the election :param suppress_validity_check: do not validate the encryption prior to decrypting (useful for tests) - :param remove_placeholders: filter out placeholder ciphertext selections after decryption """ if not suppress_validity_check and not contest.is_valid_encryption( @@ -153,11 +149,7 @@ def decrypt_contest_with_secret( suppress_validity_check, ) if plaintext_selection is not None: - if ( - not remove_placeholders - or not plaintext_selection.is_placeholder_selection - ): - plaintext_selections.append(plaintext_selection) + plaintext_selections.append(plaintext_selection) else: log_warning( f"decryption with secret failed for contest: {contest.object_id} selection: {selection.object_id}" @@ -169,24 +161,22 @@ def decrypt_contest_with_secret( def decrypt_contest_with_nonce( contest: CiphertextBallotContest, - description: ContestDescriptionWithPlaceholders, + description: ContestDescription, public_key: ElGamalPublicKey, crypto_extended_base_hash: ElementModQ, nonce_seed: Optional[ElementModQ] = None, suppress_validity_check: bool = False, - remove_placeholders: bool = True, ) -> Optional[PlaintextBallotContest]: """ Decrypt the specified `CiphertextBallotContest` within the context of the specified contest. :param contest: the contest to decrypt - :param description: the qualified contest metadata that includes placeholder selections + :param description: qualified contest metadata :param public_key: the public key for the election (K) - :param crypto_extended_base_hash: the extended base hash code (𝑄') for the election + :param crypto_extended_base_hash: the extended base hash code (Q') for the election :param nonce_seed: the optional nonce that was seeded to the encryption function if no value is provided, the nonce field from the contest is used :param suppress_validity_check: do not validate the encryption prior to decrypting (useful for tests) - :param remove_placeholders: filter out placeholder ciphertext selections after decryption """ if not suppress_validity_check and not contest.is_valid_encryption( description.crypto_hash(), public_key, crypto_extended_base_hash @@ -224,11 +214,7 @@ def decrypt_contest_with_nonce( suppress_validity_check, ) if plaintext_selection is not None: - if ( - not remove_placeholders - or not plaintext_selection.is_placeholder_selection - ): - plaintext_selections.append(plaintext_selection) + plaintext_selections.append(plaintext_selection) else: log_warning( f"decryption with nonce failed for contest: {contest.object_id} selection: {selection.object_id}" @@ -245,18 +231,16 @@ def decrypt_ballot_with_secret( public_key: ElGamalPublicKey, secret_key: ElGamalSecretKey, suppress_validity_check: bool = False, - remove_placeholders: bool = True, ) -> Optional[PlaintextBallot]: """ Decrypt the specified `CiphertextBallot` within the context of the specified election. :param ballot: the ballot to decrypt - :param internal_manifest: the qualified election metadata that includes placeholder selections - :param crypto_extended_base_hash: the extended base hash code (𝑄') for the election + :param internal_manifest: qualified election metadata + :param crypto_extended_base_hash: the extended base hash code (Q') for the election :param public_key: the public key for the election (K) :param secret_key: the known secret key used to generate the public key for this election :param suppress_validity_check: do not validate the encryption prior to decrypting (useful for tests) - :param remove_placeholders: filter out placeholder ciphertext selections after decryption """ if not suppress_validity_check and not ballot.is_valid_encryption( internal_manifest.manifest_hash, public_key, crypto_extended_base_hash @@ -275,7 +259,6 @@ def decrypt_ballot_with_secret( secret_key, crypto_extended_base_hash, suppress_validity_check, - remove_placeholders, ) if plaintext_contest is not None: plaintext_contests.append(plaintext_contest) @@ -295,18 +278,16 @@ def decrypt_ballot_with_nonce( public_key: ElGamalPublicKey, nonce: Optional[ElementModQ] = None, suppress_validity_check: bool = False, - remove_placeholders: bool = True, ) -> Optional[PlaintextBallot]: """ Decrypt the specified `CiphertextBallot` within the context of the specified election. :param ballot: the ballot to decrypt - :param internal_manifest: the qualified election metadata that includes placeholder selections - :param crypto_extended_base_hash: the extended base hash code (𝑄') for the election + :param internal_manifest: qualified election metadata + :param crypto_extended_base_hash: the extended base hash code (Q') for the election :param public_key: the public key for the election (K) :param nonce: the optional master ballot nonce that was either seeded to, or gernated by the encryption function :param suppress_validity_check: do not validate the encryption prior to decrypting (useful for tests) - :param remove_placeholders: filter out placeholder ciphertext selections after decryption """ if not suppress_validity_check and not ballot.is_valid_encryption( internal_manifest.manifest_hash, public_key, crypto_extended_base_hash @@ -340,7 +321,6 @@ def decrypt_ballot_with_nonce( crypto_extended_base_hash, nonce_seed, suppress_validity_check, - remove_placeholders, ) if plaintext_contest is not None: plaintext_contests.append(plaintext_contest) diff --git a/src/electionguard/decrypt_with_shares.py b/src/electionguard/decrypt_with_shares.py index ae849f02..d07f96b2 100644 --- a/src/electionguard/decrypt_with_shares.py +++ b/src/electionguard/decrypt_with_shares.py @@ -31,7 +31,6 @@ def decrypt_tally( shares: Dict[GuardianId, DecryptionShare], crypto_extended_base_hash: ElementModQ, manifest: Manifest, - remove_placeholders: bool = True, ) -> Optional[PlaintextTally]: """ Try to decrypt the tally and the spoiled ballots using the provided decryption shares. @@ -60,7 +59,6 @@ def decrypt_tally( shares, crypto_extended_base_hash, contest_descriptions[contest.object_id], - remove_placeholders, ) if not plaintext_contest: log_warning(f"contest: {contest.object_id} failed to decrypt with shares") @@ -75,7 +73,6 @@ def decrypt_ballot( shares: Dict[GuardianId, DecryptionShare], crypto_extended_base_hash: ElementModQ, manifest: Manifest, - remove_placeholders: bool = True, ) -> Optional[PlaintextTally]: """ Try to decrypt a single ballot using the provided decryption shares. @@ -104,7 +101,6 @@ def decrypt_ballot( shares, crypto_extended_base_hash, contest_descriptions[contest.object_id], - remove_placeholders, ) if not plaintext_contest: log_warning(f"contest: {contest.object_id} failed to decrypt with shares") @@ -119,7 +115,6 @@ def decrypt_contest_with_decryption_shares( shares: Dict[GuardianId, DecryptionShare], crypto_extended_base_hash: ElementModQ, contest_description: ContestDescription, - remove_placeholders: bool = True, ) -> Optional[PlaintextTallyContest]: """ Decrypt the specified contest within the context of the specified Decryption Shares. @@ -135,8 +130,8 @@ def decrypt_contest_with_decryption_shares( ] for selection in contest.selections: - if selection.object_id not in selection_description_ids and remove_placeholders: - continue # Skip selections not in manifest (Such as placeholders) + if selection.object_id not in selection_description_ids: + continue # Skip selections not in manifest tally_shares = get_shares_for_selection(selection.object_id, shares) plaintext_selection = decrypt_selection_with_decryption_shares( diff --git a/src/electionguard/decryption.py b/src/electionguard/decryption.py index 89fdb576..cefb3541 100644 --- a/src/electionguard/decryption.py +++ b/src/electionguard/decryption.py @@ -449,9 +449,9 @@ def partially_decrypt( proof = make_chaum_pedersen( message=elgamal, s=key_pair.key_pair.secret_key, - m=partial_decryption, + q=extended_base_hash, seed=nonce_seed, - hash_header=extended_base_hash, + m=partial_decryption, ) return (partial_decryption, proof) @@ -513,9 +513,9 @@ def decrypt_with_threshold( proof = make_chaum_pedersen( ciphertext, coordinate, - partial_decryption, - nonce_seed, extended_base_hash, + nonce_seed, + partial_decryption, ) return (partial_decryption, proof) diff --git a/src/electionguard/encrypt.py b/src/electionguard/encrypt.py index f94f4d58..3445454a 100644 --- a/src/electionguard/encrypt.py +++ b/src/electionguard/encrypt.py @@ -24,7 +24,6 @@ from .manifest import ( InternalManifest, ContestDescription, - ContestDescriptionWithPlaceholders, SelectionDescription, ) from .nonces import Nonces @@ -139,31 +138,27 @@ def generate_device_uuid() -> int: def selection_from( description: SelectionDescription, - is_placeholder: bool = False, is_affirmative: bool = False, ) -> PlaintextBallotSelection: """ - Construct a `BallotSelection` from a specific `SelectionDescription`. - This function is useful for filling selections when a voter undervotes a ballot. - It is also used to create placeholder representations when generating the `ConstantChaumPedersenProof` + Construct a `PlaintextBallotSelection` from a specific `SelectionDescription`. + This function is useful for filling selections when a voter undervotes a ballot and for testing. :param description: The `SelectionDescription` which provides the relevant `object_id` - :param is_placeholder: Mark this selection as a placeholder value :param is_affirmative: Mark this selection as `yes` - :return: A BallotSelection + :return: A plaintext selection """ return PlaintextBallotSelection( description.object_id, 1 if is_affirmative else 0, - is_placeholder, ) def contest_from(description: ContestDescription) -> PlaintextBallotContest: """ Construct a `BallotContest` from a specific `ContestDescription` with all false fields. - This function is useful for filling contests and selections when a voter undervotes a ballot. + This function is useful for filling contests and selections for a null-vote ballot. :param description: The `ContestDescription` used to derive the well-formed `BallotContest` :return: a `BallotContest` @@ -183,11 +178,11 @@ def encrypt_selection( elgamal_public_key: ElGamalPublicKey, crypto_extended_base_hash: ElementModQ, nonce_seed: ElementModQ, - is_placeholder: bool = False, + selection_limit: int = 1, should_verify_proofs: bool = False, ) -> Optional[CiphertextBallotSelection]: """ - Encrypt a specific `BallotSelection` in the context of a specific `BallotContest` + Encrypt a specific `PlaintextBallotSelection` in the context of a specific `BallotContest` :param selection: the selection in the valid input form :param selection_description: the `SelectionDescription` from the @@ -196,7 +191,7 @@ def encrypt_selection( :param crypto_extended_base_hash: the extended base hash of the election :param nonce_seed: an `ElementModQ` used as a header to seed the `Nonce` generated for this selection. this value can be (or derived from) the BallotContest nonce, but no relationship is required - :param is_placeholder: specifies if this is a placeholder selection + :param selection_limit: maximum number of votes allowed for the selection according to the contest :param should_verify_proofs: specify if the proofs should be verified prior to returning (default False) """ @@ -208,7 +203,7 @@ def encrypt_selection( selection_description_hash = selection_description.crypto_hash() nonce_sequence = Nonces(selection_description_hash, nonce_seed) selection_nonce = nonce_sequence[selection_description.sequence_order] - disjunctive_chaum_pedersen_nonce = next(iter(nonce_sequence)) + range_chaum_pedersen_nonce = next(iter(nonce_sequence)) log_info( f": encrypt_selection: for {selection_description.object_id} hash: {selection_description_hash.to_hex()}" @@ -235,9 +230,9 @@ def encrypt_selection( get_optional(elgamal_encryption), elgamal_public_key, crypto_extended_base_hash, - disjunctive_chaum_pedersen_nonce, + range_chaum_pedersen_nonce, selection_representation, - is_placeholder, + selection_limit, selection_nonce, ) @@ -261,7 +256,7 @@ def encrypt_selection( def encrypt_contest( contest: PlaintextBallotContest, - contest_description: ContestDescriptionWithPlaceholders, + contest_description: ContestDescription, elgamal_public_key: ElGamalPublicKey, crypto_extended_base_hash: ElementModQ, nonce_seed: ElementModQ, @@ -271,14 +266,12 @@ def encrypt_contest( Encrypt a specific `BallotContest` in the context of a specific `Ballot`. This method accepts a contest representation that only includes `True` selections. - It will fill missing selections for a contest with `False` values, and generate `placeholder` - selections to represent the number of seats available for a given contest. By adding `placeholder` - votes + It will fill missing selections for a contest with `False` values. :param contest: the contest in the valid input form - :param contest_description: the `ContestDescriptionWithPlaceholders` - from the `ContestDescription` which defines this contest's structure - :param elgamal_public_key: the public key (k) used to encrypt the ballot + :param contest_description: the `ContestDescription` + which defines this contest's structure + :param elgamal_public_key: the public key (K) used to encrypt the ballot :param crypto_extended_base_hash: the extended base hash of the election :param nonce_seed: an `ElementModQ` used as a header to seed the `Nonce` generated for this contest. this value can be (or derived from) the Ballot nonce, but no relationship is required @@ -307,7 +300,9 @@ def encrypt_contest( encrypted_selections: List[CiphertextBallotSelection] = [] - selection_count = 0 + votes_allowed = contest_description.votes_allowed + selection_limit = contest_description.votes_allowed_per_selection + number_votes = 0 # TODO: ISSUE #54 this code could be inefficient if we had a contest # with a lot of choices, although the O(n^2) iteration here is small @@ -323,21 +318,21 @@ def encrypt_contest( # false is entered instead and the selection_count is not incremented # this allows consumers to only pass in the relevant selections made by a voter for selection in contest.ballot_selections: - # If overvote, no votes should be counted and instead placeholders should be used. + # If overvote, no votes should be counted if ( selection.object_id == description.object_id and error is not ContestErrorType.OverVote ): - # track the selection count so we can append the - # appropriate number of true placeholder votes + # Track the selection count so we can construct the range Chaum-Pedersen proof has_selection = True - selection_count += selection.vote + number_votes += selection.vote encrypted_selection = encrypt_selection( selection, description, elgamal_public_key, crypto_extended_base_hash, contest_nonce, + selection_limit, should_verify_proofs=should_verify_proofs, ) break @@ -351,6 +346,7 @@ def encrypt_contest( elgamal_public_key, crypto_extended_base_hash, contest_nonce, + selection_limit, should_verify_proofs=should_verify_proofs, ) @@ -358,37 +354,6 @@ def encrypt_contest( return None # log will have happened earlier encrypted_selections.append(get_optional(encrypted_selection)) - # Handle Placeholder selections - # After we loop through all of the real selections on the ballot, - # we loop through each placeholder value and determine if it should be filled in - - # Add a placeholder selection for each possible seat in the contest - for placeholder in contest_description.placeholder_selections: - # for undervotes, select the placeholder value as true for each available seat - # note this pattern is used since DisjunctiveChaumPedersen expects a 0 or 1 - # so each seat can only have a maximum value of 1 in the current implementation - select_placeholder = False - if selection_count < contest_description.number_elected: - select_placeholder = True - selection_count += 1 - - encrypted_selection = encrypt_selection( - selection=selection_from( - description=placeholder, - is_placeholder=True, - is_affirmative=select_placeholder, - ), - selection_description=placeholder, - elgamal_public_key=elgamal_public_key, - crypto_extended_base_hash=crypto_extended_base_hash, - nonce_seed=contest_nonce, - is_placeholder=True, - should_verify_proofs=should_verify_proofs, - ) - if encrypted_selection is None: - return None # log will have happened earlier - encrypted_selections.append(get_optional(encrypted_selection)) - encrypted_contest_data = hashed_elgamal_encrypt( ContestData(error, error_data, contest.write_ins).to_bytes(), Nonces(contest_nonce, "constant-extended-data")[0], @@ -405,7 +370,8 @@ def encrypt_contest( elgamal_public_key, crypto_extended_base_hash, chaum_pedersen_nonce, - contest_description.number_elected, + number_votes=number_votes, + votes_allowed=votes_allowed, nonce=contest_nonce, extended_data=encrypted_contest_data, ) @@ -440,11 +406,10 @@ def encrypt_ballot( Encrypt a specific `Ballot` in the context of a specific `CiphertextElectionContext`. This method accepts a ballot representation that only includes `True` selections. - It will fill missing selections for a contest with `False` values, and generate `placeholder` - selections to represent the number of seats available for a given contest. + It will fill missing selections for a contest with `False` values. This method also allows for ballots to exclude passing contests for which the voter made no selections. - It will fill missing contests with `False` selections and generate `placeholder` selections that are marked `True`. + It will fill missing contests with `False` selections. :param ballot: the ballot in the valid input form :param internal_manifest: the `InternalManifest` which defines this ballot's structure diff --git a/src/electionguard/manifest.py b/src/electionguard/manifest.py index 53aad3a4..311617e5 100644 --- a/src/electionguard/manifest.py +++ b/src/electionguard/manifest.py @@ -7,7 +7,7 @@ from .group import ElementModQ from .hash import CryptoHashable, hash_elems from .logs import log_warning, log_debug -from .utils import get_optional, to_iso_date_string +from .utils import to_iso_date_string @unique @@ -89,6 +89,7 @@ class VoteVariationType(Enum): SUPPORTED_VOTE_VARIATIONS = [ VoteVariationType.one_of_m, VoteVariationType.approval, + VoteVariationType.cumulative, VoteVariationType.majority, VoteVariationType.n_of_m, VoteVariationType.plurality, @@ -293,7 +294,7 @@ class Candidate(ElectionObjectBase, CryptoHashable): """ Entity describing information about a candidate in a contest. See: https://developers.google.com/elections-data/reference/candidate - Note: The ElectionGuard Data Spec deviates from the NIST model in that + Note: The ElectionGuard specification deviates from the NIST model in that selections for any contest type are considered a "candidate". for instance, on a yes-no referendum contest, two `candidate` objects would be included in the model to represent the `affirmative` and `negative` @@ -325,9 +326,8 @@ class SelectionDescription(OrderedObjectBase, CryptoHashable): Data entity for the ballot selections in a contest, for example linking candidates and parties to their vote counts. See: https://developers.google.com/elections-data/reference/ballot-selection - Note: The ElectionGuard Data Spec deviates from the NIST model in that - there is no difference for different types of selections. - The ElectionGuard Data Spec deviates from the NIST model in that + Note: The ElectionGuard specification deviates from the NIST model in that + there is no difference for different types of selections and that `sequence_order` is a required field since it is used for ordering selections in a contest to ensure various encryption primitives are deterministic. For a given election, the sequence of selections displayed to a user may be different @@ -352,7 +352,7 @@ class ContestDescription(OrderedObjectBase, CryptoHashable): Use this data entity for describing a contest and linking the contest to the associated candidates and parties. See: https://developers.google.com/elections-data/reference/contest - Note: The ElectionGuard Data Spec deviates from the NIST model in that + Note: The ElectionGuard specification deviates from the NIST model in that `sequence_order` is a required field since it is used for ordering selections in a contest to ensure various encryption primitives are deterministic. For a given election, the sequence of contests displayed to a user may be different @@ -361,19 +361,20 @@ class ContestDescription(OrderedObjectBase, CryptoHashable): electoral_district_id: str + # Name of the contest, not necessarily as it appears on the ballot. + name: str + vote_variation: VoteVariationType - # Number of candidates that are elected in the contest ("n" of n-of-m). - # Note: a referendum is considered a specific case of 1-of-m in ElectionGuard - number_elected: int + # Maximum number of votes (or write-ins) per voter in this contest. + # E.g., in an n-of-m contest, this value is n. + # E.g., in a cumulative voting contest, this value is the total allotment. + votes_allowed: int = 1 - # Maximum number of votes/write-ins per voter in this contest. Used in cumulative voting - # to indicate how many total votes a voter can spread around. In n-of-m elections, this will - # be None. - votes_allowed: Optional[int] - - # Name of the contest, not necessarily as it appears on the ballot. - name: str + # Maximum number of votes a voter may give to each selection in this contest. + # E.g., in an n-of-m contest, this value is 1. + # E.g., in a cumulative voting contest, this value is usually the total allotment. + votes_allowed_per_selection: int = 1 # For associating a ballot selection for the contest, i.e., a candidate, a ballot measure. ballot_selections: List[SelectionDescription] = field(default_factory=lambda: []) @@ -389,10 +390,10 @@ def __eq__(self, other: Any) -> bool: isinstance(other, ContestDescription) and self.electoral_district_id == other.electoral_district_id and self.sequence_order == other.sequence_order + and self.name == other.name and self.vote_variation == other.vote_variation - and self.number_elected == other.number_elected and self.votes_allowed == other.votes_allowed - and self.name == other.name + and self.votes_allowed_per_selection == other.votes_allowed_per_selection and list_eq(self.ballot_selections, other.ballot_selections) and self.ballot_title == other.ballot_title and self.ballot_subtitle == other.ballot_subtitle @@ -410,12 +411,12 @@ def crypto_hash(self) -> ElementModQ: self.object_id, self.sequence_order, self.electoral_district_id, - str(self.vote_variation.name), + self.vote_variation.name, self.ballot_title, self.ballot_subtitle, self.name, - self.number_elected, self.votes_allowed, + self.votes_allowed_per_selection, self.ballot_selections, ) return hash @@ -424,11 +425,13 @@ def is_valid(self) -> bool: """ Check the validity of the contest object by verifying its data """ - contest_has_valid_number_elected = self.number_elected <= len( - self.ballot_selections + + contest_has_supported_vote_variation = ( + self.vote_variation in SUPPORTED_VOTE_VARIATIONS ) contest_has_valid_votes_allowed = ( - self.votes_allowed is None or self.number_elected <= self.votes_allowed + self.votes_allowed + <= len(self.ballot_selections) * self.votes_allowed_per_selection ) # verify the candidate_ids, selection object_ids, and sequence_ids are unique @@ -450,27 +453,22 @@ def is_valid(self) -> bool: if selection.candidate_id not in candidate_ids: candidate_ids.add(selection.candidate_id) - selections_have_valid_candidate_ids = ( - len(candidate_ids) == expected_selection_count - ) selections_have_valid_selection_ids = ( len(selection_ids) == expected_selection_count ) selections_have_valid_sequence_ids = ( len(sequence_ids) == expected_selection_count ) - - contest_has_supported_vote_variation = ( - self.vote_variation in SUPPORTED_VOTE_VARIATIONS + selections_have_valid_candidate_ids = ( + len(candidate_ids) == expected_selection_count ) success = ( - contest_has_valid_number_elected + contest_has_supported_vote_variation and contest_has_valid_votes_allowed - and selections_have_valid_candidate_ids and selections_have_valid_selection_ids and selections_have_valid_sequence_ids - and contest_has_supported_vote_variation + and selections_have_valid_candidate_ids ) if not success: @@ -479,25 +477,39 @@ def is_valid(self) -> bool: self.object_id, str( { - "contest_has_valid_number_elected": contest_has_valid_number_elected, + "contest_has_supported_vote_variation": contest_has_supported_vote_variation, "contest_has_valid_votes_allowed": contest_has_valid_votes_allowed, - "selections_have_valid_candidate_ids": selections_have_valid_candidate_ids, "selections_have_valid_selection_ids": selections_have_valid_selection_ids, "selections_have_valid_sequence_ids": selections_have_valid_sequence_ids, - "contest_has_supported_vote_variation": contest_has_supported_vote_variation, + "selections_have_valid_candidate_ids": selections_have_valid_candidate_ids, } ), ) return success + def selection_for(self, selection_id: str) -> Optional[SelectionDescription]: + """ + Gets the description for a particular id + :param selection_id: Id of Selection + :return: description + """ + matching_selections = list( + filter(lambda i: i.object_id == selection_id, self.ballot_selections) + ) + + if any(matching_selections): + return matching_selections[0] + + return None + @dataclass(eq=True, unsafe_hash=True) class CandidateContestDescription(ContestDescription): """ Use this entity to describe a contest that involves selecting one or more candidates. See: https://developers.google.com/elections-data/reference/contest - Note: The ElectionGuard Data Spec deviates from the NIST model in that + Note: The ElectionGuard specification deviates from the NIST model in that this subclass is used purely for convenience """ @@ -509,64 +521,11 @@ class ReferendumContestDescription(ContestDescription): """ Use this entity to describe a contest that involves selecting exactly one 'candidate'. See: https://developers.google.com/elections-data/reference/contest - Note: The ElectionGuard Data Spec deviates from the NIST model in that + Note: The ElectionGuard specification deviates from the NIST model in that this subclass is used purely for convenience """ -@dataclass(eq=True, unsafe_hash=True) -class ContestDescriptionWithPlaceholders(ContestDescription): - """ - ContestDescriptionWithPlaceholders is a `ContestDescription` with ElectionGuard `placeholder_selections`. - (The ElectionGuard spec requires for n-of-m elections that there be *exactly* n counters that are one - with the rest zero, so if a voter deliberately undervotes, one or more of the placeholder counters will - become one. This allows the `ConstantChaumPedersenProof` to verify correctly for undervoted contests.) - """ - - placeholder_selections: List[SelectionDescription] = field( - default_factory=lambda: [] - ) - - def is_valid(self) -> bool: - """ - Checks is contest description is valid - :return: true if valid - """ - contest_description_validates = super().is_valid() - return ( - contest_description_validates - and len(self.placeholder_selections) == self.number_elected - ) - - def is_placeholder(self, selection: SelectionDescription) -> bool: - """ - Checks is contest description is placeholder - :return: true if placeholder - """ - return selection in self.placeholder_selections - - def selection_for(self, selection_id: str) -> Optional[SelectionDescription]: - """ - Gets the description for a particular id - :param selection_id: Id of Selection - :return: description - """ - matching_selections = list( - filter(lambda i: i.object_id == selection_id, self.ballot_selections) - ) - - if any(matching_selections): - return matching_selections[0] - - matching_placeholders = list( - filter(lambda i: i.object_id == selection_id, self.placeholder_selections) - ) - - if any(matching_placeholders): - return matching_placeholders[0] - return None - - class SpecVersion(Enum): """Specify ElectionGuard Versions""" @@ -580,11 +539,11 @@ class Manifest(CryptoHashable): """ Use this entity for defining the structure of the election and associated information such as candidates, contests, and vote counts. This class is - based on the NIST Election Common Standard Data Specification. Some deviations + based on the NIST Election Common Standard specificationification. Some deviations from the standard exist. This structure is considered an immutable input object and should not be changed - through the course of an election, as it's hash representation is the basis for all + through the course of an election, as its hash representation is the basis for all other hash representations within an ElectionGuard election context. See: https://developers.google.com/elections-data/reference/election @@ -737,9 +696,7 @@ def is_valid(self) -> bool: for contest in self.contests: - contests_validate_their_properties = ( - contests_validate_their_properties and contest.is_valid() - ) + contests_validate_their_properties &= contest.is_valid() if contest.object_id not in contest_ids: contest_ids.add(contest.object_id) @@ -837,7 +794,7 @@ def _replace_candidate_ids_with_names( def get_selection_names(self, lang: str) -> Dict[str, str]: """ - Retrieves a dictionary whose keys are all selection id's and whose values are + Retrieves a dictionary whose keys are all selection ids and whose values are those selection's candidate names in the supplied language if available """ candidates = self._get_candidate_names(lang) @@ -847,8 +804,8 @@ def get_selection_names(self, lang: str) -> Dict[str, str]: def get_contest_names(self) -> Dict[str, str]: """ - Retrieves a dictionary whose keys are all contest id's and whose values are - those contest's names + Retrieves a dictionary whose keys are all contest ids and whose values are + those contests' names """ return {contest.object_id: contest.name for contest in self.contests} @@ -865,14 +822,14 @@ class InternalManifest: """ `InternalManifest` is a subset of the `Manifest` structure that specifies the components that ElectionGuard uses for conducting an election. The key component is the - `contests` collection, which applies placeholder selections to the `Manifest` contests + `contests` collection, which captures the `Manifest` contests """ manifest: InitVar[Manifest] = None geopolitical_units: List[GeopoliticalUnit] = field(init=False) - contests: List[ContestDescriptionWithPlaceholders] = field(init=False) + contests: List[ContestDescription] = field(init=False) ballot_styles: List[BallotStyle] = field(init=False) @@ -882,17 +839,13 @@ def __post_init__(self, manifest: Manifest) -> None: object.__setattr__(self, "manifest_hash", manifest.crypto_hash()) object.__setattr__(self, "geopolitical_units", manifest.geopolitical_units) object.__setattr__(self, "ballot_styles", manifest.ballot_styles) - object.__setattr__( - self, "contests", self._generate_contests_with_placeholders(manifest) - ) + object.__setattr__(self, "contests", manifest.contests) - def contest_for( - self, contest_id: str - ) -> Optional[ContestDescriptionWithPlaceholders]: + def contest_for(self, contest_id: str) -> Optional[ContestDescription]: """ Get contest by id :param contest_id: Contest id - :return: Contest description or none + :return: Contest description or None """ matching_contests = list( filter(lambda i: i.object_id == contest_id, self.contests) @@ -911,9 +864,7 @@ def get_ballot_style(self, ballot_style_id: str) -> BallotStyle: )[0] return style - def get_contests_for( - self, ballot_style_id: str - ) -> List[ContestDescriptionWithPlaceholders]: + def get_contests_for(self, ballot_style_id: str) -> List[ContestDescription]: """ Get contests for a ballot style :param ballot_style_id: ballot style id @@ -929,101 +880,6 @@ def get_contests_for( ) return contests - @staticmethod - def _generate_contests_with_placeholders( - manifest: Manifest, - ) -> List[ContestDescriptionWithPlaceholders]: - """ - For each contest, append the `number_elected` number - of placeholder selections to the end of the contest collection - """ - contests: List[ContestDescriptionWithPlaceholders] = [] - for contest in manifest.contests: - placeholder_selections = generate_placeholder_selections_from( - contest, contest.number_elected - ) - contests.append( - contest_description_with_placeholders_from( - contest, placeholder_selections - ) - ) - - return contests - - -def contest_description_with_placeholders_from( - description: ContestDescription, placeholders: List[SelectionDescription] -) -> ContestDescriptionWithPlaceholders: - """ - Generates a placeholder selection description - :param description: contest description - :param placeholders: list of placeholder descriptions of selections - :return: a SelectionDescription or None - """ - return ContestDescriptionWithPlaceholders( - object_id=description.object_id, - electoral_district_id=description.electoral_district_id, - sequence_order=description.sequence_order, - vote_variation=description.vote_variation, - number_elected=description.number_elected, - votes_allowed=description.votes_allowed, - name=description.name, - ballot_selections=description.ballot_selections, - ballot_title=description.ballot_title, - ballot_subtitle=description.ballot_subtitle, - placeholder_selections=placeholders, - ) - - -def generate_placeholder_selection_from( - contest: ContestDescription, use_sequence_id: Optional[int] = None -) -> Optional[SelectionDescription]: - """ - Generates a placeholder selection description that is unique so it can be hashed - - :param use_sequence_id: an optional integer unique to the contest identifying this selection's place in the contest - :return: a SelectionDescription or None - """ - sequence_ids = [selection.sequence_order for selection in contest.ballot_selections] - if use_sequence_id is None: - # if no sequence order is specified, take the max - use_sequence_id = max(*sequence_ids) + 1 - elif use_sequence_id in sequence_ids: - log_warning( - f"mismatched placeholder selection {use_sequence_id} already exists" - ) - return None - - placeholder_object_id = f"{contest.object_id}-{use_sequence_id}" - return SelectionDescription( - f"{placeholder_object_id}-placeholder", - use_sequence_id, - f"{placeholder_object_id}-candidate", - ) - - -def generate_placeholder_selections_from( - contest: ContestDescription, count: int -) -> List[SelectionDescription]: - """ - Generates the specified number of placeholder selections in - ascending sequence order from the max selection sequence orderf - - :param contest: ContestDescription for input - :param count: optionally specify a number of placeholders to generate - :return: a collection of `SelectionDescription` objects, which may be empty - """ - max_sequence_order = max( - [selection.sequence_order for selection in contest.ballot_selections] - ) - selections: List[SelectionDescription] = [] - for i in range(count): - sequence_order = max_sequence_order + 1 + i - selections.append( - get_optional(generate_placeholder_selection_from(contest, sequence_order)) - ) - return selections - def get_i8n_value(name: InternationalizedText, lang: str, default_val: str) -> str: query = (t.value for t in name.text if t.language == lang) diff --git a/src/electionguard/proof.py b/src/electionguard/proof.py index bbe3c550..6f0b7711 100644 --- a/src/electionguard/proof.py +++ b/src/electionguard/proof.py @@ -8,8 +8,9 @@ class ProofUsage(Enum): Unknown = "Unknown" SecretValue = "Prove knowledge of secret value" - SelectionLimit = "Prove value within selection's limit" - SelectionValue = "Prove selection's value (0 or 1)" + ConstantValue = "Prove value is a given constant" + RangeValue = "Prove value is within a given range (0, 1, ..., or limit)" + BinaryValue = "Prove value is binary (0 or 1)" class Proof: diff --git a/src/electionguard/tally.py b/src/electionguard/tally.py index db1f0f22..81618420 100644 --- a/src/electionguard/tally.py +++ b/src/electionguard/tally.py @@ -113,11 +113,7 @@ def accumulate_contest( # Validate the input data by comparing the selection id's provided # to the valid selection id's for this tally contest - selection_ids = { - selection.object_id - for selection in contest_selections - if not selection.is_placeholder_selection - } + selection_ids = {selection.object_id for selection in contest_selections} if any(set(self.selections).difference(selection_ids)): log_warning( @@ -369,7 +365,6 @@ def _build_tally_collection( cast_collection: Dict[str, CiphertextTallyContest] = {} for contest in internal_manifest.contests: # build a collection of valid selections for the contest description - # note: we explicitly ignore the Placeholder Selections. contest_selections: Dict[str, CiphertextTallySelection] = {} for selection in contest.ballot_selections: contest_selections[selection.object_id] = CiphertextTallySelection( diff --git a/src/electionguard_tools/factories/ballot_factory.py b/src/electionguard_tools/factories/ballot_factory.py index d92a70a8..c3abb88a 100644 --- a/src/electionguard_tools/factories/ballot_factory.py +++ b/src/electionguard_tools/factories/ballot_factory.py @@ -1,4 +1,4 @@ -from typing import Any, TypeVar, Callable, List, Tuple, Optional +from typing import Any, Optional, TypeVar, Callable, List, Tuple import os from random import Random, randint import uuid @@ -42,11 +42,10 @@ class BallotFactory: def get_random_selection_from( description: SelectionDescription, random_source: Random, - is_placeholder: bool = False, + limit: int = 1, ) -> PlaintextBallotSelection: - - selected = bool(random_source.randint(0, 1)) - return selection_from(description, is_placeholder, selected) + selected = bool(random_source.randint(0, limit)) + return selection_from(description, selected) @staticmethod def get_random_contest_from( @@ -68,15 +67,14 @@ def get_random_contest_from( shuffled_selections = description.ballot_selections[:] random.shuffle(shuffled_selections) - if allow_null_votes and not allow_under_votes: - cut_point = random.choice([0, description.number_elected]) - else: - min_votes = description.number_elected - if allow_under_votes: - min_votes = 1 - if allow_null_votes: - min_votes = 0 - cut_point = random.randint(min_votes, description.number_elected) + min_votes = ( + 0 + if allow_null_votes + else 1 + if allow_under_votes + else description.votes_allowed + ) + cut_point = random.choice([min_votes, description.votes_allowed]) selections = [ selection_from(selection_description, is_affirmative=True) @@ -86,7 +84,7 @@ def get_random_contest_from( for selection_description in shuffled_selections[cut_point:]: # Possibly append the false selections as well, indicating some choices # may be explicitly false - if bool(random.randint(0, 1)) == 1: + if random.randint(0, 1): selections.append( selection_from(selection_description, is_affirmative=False) ) @@ -101,7 +99,7 @@ def get_fake_ballot( allow_null_votes: bool = False, ) -> PlaintextBallot: """ - Get a single Fake Ballot object that is manually constructed with default vaules + Get a single Fake Ballot object that is manually constructed with default values """ if ballot_id is None: @@ -188,7 +186,7 @@ def get_selection_well_formed( object_id = f"selection-{draw(ids)}" return ( object_id, - PlaintextBallotSelection(object_id, draw(vote), draw(bools), extended_data), + PlaintextBallotSelection(object_id, draw(vote), extended_data), ) @@ -209,5 +207,5 @@ def get_selection_poorly_formed( object_id = f"selection-{draw(ids)}" return ( object_id, - PlaintextBallotSelection(object_id, draw(vote), draw(bools), extended_data), + PlaintextBallotSelection(object_id, draw(vote), extended_data), ) diff --git a/src/electionguard_tools/factories/election_factory.py b/src/electionguard_tools/factories/election_factory.py index fdeaf8aa..5cfd919b 100644 --- a/src/electionguard_tools/factories/election_factory.py +++ b/src/electionguard_tools/factories/election_factory.py @@ -29,7 +29,6 @@ ElectionType, InternalManifest, SpecVersion, - generate_placeholder_selections_from, GeopoliticalUnit, Candidate, Party, @@ -37,7 +36,6 @@ SelectionDescription, ReportingUnitType, VoteVariationType, - contest_description_with_placeholders_from, CandidateContestDescription, ReferendumContestDescription, ) @@ -158,6 +156,7 @@ def get_fake_manifest() -> Manifest: fake_ballot_style = BallotStyle("some-ballot-style-id") fake_ballot_style.geopolitical_unit_ids = ["some-geopoltical-unit-id"] + # Referendum contest fake_referendum_ballot_selections = [ # Referendum selections are simply a special case of `candidate` in the object model SelectionDescription( @@ -165,21 +164,21 @@ def get_fake_manifest() -> Manifest: ), SelectionDescription("some-object-id-negative", 1, "some-candidate-id-2"), ] - sequence_order = 0 - number_elected = 1 votes_allowed = 1 + votes_allowed_per_selection = 1 fake_referendum_contest = ReferendumContestDescription( "some-referendum-contest-object-id", sequence_order, "some-geopoltical-unit-id", + "some-referendum-contest-name", VoteVariationType.one_of_m, - number_elected, votes_allowed, - "some-referendum-contest-name", + votes_allowed_per_selection, fake_referendum_ballot_selections, ) + # 1-of-m contest fake_candidate_ballot_selections = [ SelectionDescription( "some-object-id-candidate-1", 0, "some-candidate-id-1" @@ -191,21 +190,49 @@ def get_fake_manifest() -> Manifest: "some-object-id-candidate-3", 2, "some-candidate-id-3" ), ] - sequence_order_2 = 1 - number_elected_2 = 2 votes_allowed_2 = 2 + votes_allowed_per_selection_2 = 2 fake_candidate_contest = CandidateContestDescription( "some-candidate-contest-object-id", sequence_order_2, "some-geopoltical-unit-id", + "some-candidate-contest-name", VoteVariationType.one_of_m, - number_elected_2, votes_allowed_2, - "some-candidate-contest-name", + votes_allowed_per_selection_2, fake_candidate_ballot_selections, ) + # Cumulative voting contest + fake_cumulative_ballot_selections = [ + SelectionDescription( + "some-object-id-candidate-4", 0, "some-candidate-id-4" + ), + SelectionDescription( + "some-object-id-candidate-5", 1, "some-candidate-id-5" + ), + SelectionDescription( + "some-object-id-candidate-6", 2, "some-candidate-id-6" + ), + SelectionDescription( + "some-object-id-candidate-7", 3, "some-candidate-id-7" + ), + ] + sequence_order_3 = 2 + votes_allowed_3 = 2 + votes_allowed_per_selection_3 = 2 + fake_cumulative_contest = CandidateContestDescription( + "some-cumulative-contest-object-id", + sequence_order_3, + "some-geopoltical-unit-id", + "some-cumulative-contest-name", + VoteVariationType.cumulative, + votes_allowed_3, + votes_allowed_per_selection_3, + fake_cumulative_ballot_selections, + ) + fake_manifest = Manifest( spec_version=SpecVersion.EG0_95, election_scope_id="some-scope-id", @@ -224,8 +251,16 @@ def get_fake_manifest() -> Manifest: Candidate("some-candidate-id-1"), Candidate("some-candidate-id-2"), Candidate("some-candidate-id-3"), + Candidate("some-candidate-id-4"), + Candidate("some-candidate-id-5"), + Candidate("some-candidate-id-6"), + Candidate("some-candidate-id-7"), + ], + contests=[ + fake_referendum_contest, + fake_candidate_contest, + fake_cumulative_contest, ], - contests=[fake_referendum_contest, fake_candidate_contest], ballot_styles=[fake_ballot_style], ) @@ -256,7 +291,7 @@ def get_fake_ballot( fake_ballot = PlaintextBallot( ballot_id, manifest.ballot_styles[0].object_id, - [contest_from(manifest.contests[0]), contest_from(manifest.contests[1])], + [contest_from(contest) for contest in manifest.contests], ) return fake_ballot @@ -306,8 +341,9 @@ def get_contest_description_well_formed( selections: Any = get_selection_description_well_formed(), sequence_order: Optional[int] = None, electoral_district_id: Optional[str] = None, + variation: VoteVariationType = VoteVariationType.n_of_m, ) -> Tuple[str, ContestDescription]: - """Get mock well formed selection contest.""" + """Get mock well-formed selection contest.""" object_id = f"{draw(email_addresses)}-contest" if sequence_order is None: @@ -316,15 +352,21 @@ def get_contest_description_well_formed( if electoral_district_id is None: electoral_district_id = f"{draw(email_addresses)}-gp-unit" - first_int = draw(ints) - second_int = draw(ints) + ints = sorted([draw(ints) for i in range(3)], reverse=True) + num_selections = ints[0] - # TODO ISSUE #33: support more votes than seats for other VoteVariationType options - number_elected = min(first_int, second_int) - votes_allowed = number_elected + if variation == VoteVariationType.cumulative: + votes_allowed = ints[1] + votes_allowed_per_selection = ints[2] + elif variation == VoteVariationType.one_of_m: + votes_allowed = 1 + votes_allowed_per_selection = 1 + else: + votes_allowed = ints[1] + votes_allowed_per_selection = 1 selection_descriptions: List[SelectionDescription] = [] - for i in range(max(first_int, second_int)): + for i in range(num_selections): selection: Tuple[str, SelectionDescription] = draw(selections) _, selection_description = selection selection_description.sequence_order = i @@ -334,20 +376,11 @@ def get_contest_description_well_formed( object_id, sequence_order, electoral_district_id, - VoteVariationType.n_of_m, - number_elected, - votes_allowed, draw(txt), + variation, + votes_allowed, + votes_allowed_per_selection, selection_descriptions, ) - placeholder_selections = generate_placeholder_selections_from( - contest_description, number_elected - ) - - return ( - object_id, - contest_description_with_placeholders_from( - contest_description, placeholder_selections - ), - ) + return (object_id, contest_description) diff --git a/src/electionguard_tools/helpers/tally_accumulate.py b/src/electionguard_tools/helpers/tally_accumulate.py index 285b07f1..22f784ac 100644 --- a/src/electionguard_tools/helpers/tally_accumulate.py +++ b/src/electionguard_tools/helpers/tally_accumulate.py @@ -17,9 +17,6 @@ def accumulate_plaintext_ballots(ballots: List[PlaintextBallot]) -> Dict[str, in for ballot in ballots: for contest in ballot.contests: for selection in contest.ballot_selections: - assert ( - not selection.is_placeholder_selection - ), "Placeholder selections should not exist in the plaintext ballots" desc_id = selection.object_id if desc_id not in tally: tally[desc_id] = 0 diff --git a/src/electionguard_tools/strategies/election.py b/src/electionguard_tools/strategies/election.py index 64e78d11..b0145041 100644 --- a/src/electionguard_tools/strategies/election.py +++ b/src/electionguard_tools/strategies/election.py @@ -422,8 +422,8 @@ def candidate_contest_descriptions( ].object_id, sequence_order=sequence_order, vote_variation=vote_variation, - number_elected=n, - votes_allowed=n, # should this be None or n? + votes_allowed=n, + votes_allowed_per_selection=1, name=draw(emails()), ballot_selections=selection_descriptions, ballot_title=draw(internationalized_texts()), @@ -489,8 +489,8 @@ def referendum_contest_descriptions( ].object_id, sequence_order=sequence_order, vote_variation=VoteVariationType.one_of_m, - number_elected=1, - votes_allowed=1, # should this be None or 1? + votes_allowed=1, + votes_allowed_per_selection=1, name=draw(emails()), ballot_selections=selection_descriptions, ballot_title=draw(internationalized_texts()), @@ -617,7 +617,7 @@ def plaintext_voted_ballot( voted_contests: List[PlaintextBallotContest] = [] for contest in contests: assert contest.is_valid(), "every contest needs to be valid" - n = contest.number_elected # we need exactly this many 1's, and the rest 0's + n = contest.votes_allowed # We may have at most this many encryptions of one ballot_selections = deepcopy(contest.ballot_selections) assert len(ballot_selections) >= n @@ -628,10 +628,10 @@ def plaintext_voted_ballot( no_votes = ballot_selections[cut_point:] voted_selections = [ - selection_from(description, is_placeholder=False, is_affirmative=True) + selection_from(description, is_affirmative=True) for description in yes_votes ] + [ - selection_from(description, is_placeholder=False, is_affirmative=False) + selection_from(description, is_affirmative=False) for description in no_votes ] diff --git a/tests/integration/test_end_to_end_election.py b/tests/integration/test_end_to_end_election.py index 3960f282..899cf7e5 100644 --- a/tests/integration/test_end_to_end_election.py +++ b/tests/integration/test_end_to_end_election.py @@ -139,7 +139,7 @@ def test_end_to_end_election(self) -> None: def step_0_configure_election(self) -> None: """ - To conduct an election, load an `Manifest` file. + To conduct an election, load a `Manifest` file. """ # Load a pre-configured Election Description diff --git a/tests/property/test_ballot.py b/tests/property/test_ballot.py index 1362f3b7..b2adb50f 100644 --- a/tests/property/test_ballot.py +++ b/tests/property/test_ballot.py @@ -26,11 +26,12 @@ def test_ballot_is_valid(self): self.assertIsNotNone(subject.object_id) self.assertEqual(subject.object_id, "some-external-id-string-123") self.assertTrue(subject.is_valid("jefferson-county-ballot-style")) - self.assertTrue(first_contest.is_valid("justice-supreme-court", 2, 2)) - self.assertFalse(first_contest.is_valid("some-other-contest", 2, 2)) - self.assertFalse(first_contest.is_valid("justice-supreme-court", 1, 2)) - self.assertFalse(first_contest.is_valid("justice-supreme-court", 2, 1)) - self.assertFalse(first_contest.is_valid("justice-supreme-court", 2, 2, 1)) + self.assertTrue(first_contest.is_valid("justice-supreme-court", 2, 2, 1)) + self.assertFalse(first_contest.is_valid("some-other-contest", 2, 2, 1)) + self.assertFalse(first_contest.is_valid("justice-supreme-court", 1, 2, 1)) + self.assertFalse(first_contest.is_valid("justice-supreme-court", 2, 1, 1)) + # Increasing the selection limit should not break validation + self.assertTrue(first_contest.is_valid("justice-supreme-court", 2, 2, 2)) @settings( deadline=timedelta(milliseconds=2000), diff --git a/tests/property/test_chaum_pedersen.py b/tests/property/test_chaum_pedersen.py index 040ec982..c08882f6 100644 --- a/tests/property/test_chaum_pedersen.py +++ b/tests/property/test_chaum_pedersen.py @@ -12,6 +12,7 @@ make_chaum_pedersen, make_constant_chaum_pedersen, make_disjunctive_chaum_pedersen, + make_range_chaum_pedersen, ) from electionguard.elgamal import ( ElGamalKeyPair, @@ -24,8 +25,143 @@ from electionguard_tools.strategies.group import elements_mod_q_no_zero, elements_mod_q +class TestRangeChaumPedersen(BaseTestCase): + """Range Chaum-Pedersen tests""" + + def test_rcp_proofs_simple(self): + keypair = elgamal_keypair_from_secret(TWO_MOD_Q) + nonce = ONE_MOD_Q + seed = TWO_MOD_Q + + # Encode 0; able to prove within range 0 - L for any L >= 0 (default 1) + message0 = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) + proof00 = make_range_chaum_pedersen( + message0, nonce, keypair.public_key, ONE_MOD_Q, seed, 0, 0 + ) + self.assertTrue(proof00.is_valid(message0, keypair.public_key, ONE_MOD_Q)) + proof01 = make_range_chaum_pedersen( + message0, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 + ) + self.assertTrue(proof01.is_valid(message0, keypair.public_key, ONE_MOD_Q)) + proof02 = make_range_chaum_pedersen( + message0, nonce, keypair.public_key, ONE_MOD_Q, seed, 0, 2 + ) + self.assertTrue(proof02.is_valid(message0, keypair.public_key, ONE_MOD_Q)) + + # Encode 1; able to prove within range 0 - L for any L >= 1 (default 1) + message1 = get_optional(elgamal_encrypt(1, nonce, keypair.public_key)) + proof11 = make_range_chaum_pedersen( + message1, nonce, keypair.public_key, ONE_MOD_Q, seed, 1 + ) + self.assertTrue(proof11.is_valid(message1, keypair.public_key, ONE_MOD_Q)) + proof12 = make_range_chaum_pedersen( + message1, nonce, keypair.public_key, ONE_MOD_Q, seed, 1, 2 + ) + self.assertTrue(proof12.is_valid(message1, keypair.public_key, ONE_MOD_Q)) + + # Encode 2; able to prove within range 0 - L for any L >= 2 + message2 = get_optional(elgamal_encrypt(2, nonce, keypair.public_key)) + proof22 = make_range_chaum_pedersen( + message2, nonce, keypair.public_key, ONE_MOD_Q, seed, 2, 2 + ) + self.assertTrue(proof22.is_valid(message2, keypair.public_key, ONE_MOD_Q)) + proof23 = make_range_chaum_pedersen( + message2, nonce, keypair.public_key, ONE_MOD_Q, seed, 2, 5 + ) + self.assertTrue(proof23.is_valid(message2, keypair.public_key, ONE_MOD_Q)) + + def test_rcp_proofs_invalid_input(self): + keypair = elgamal_keypair_from_secret(TWO_MOD_Q) + nonce = ONE_MOD_Q + seed = TWO_MOD_Q + + # Encode 2; cannot construct range proof for range not including plaintext + message2 = get_optional(elgamal_encrypt(2, nonce, keypair.public_key)) + self.assertRaises( + AssertionError, + make_range_chaum_pedersen, + message2, + nonce, + keypair.public_key, + ONE_MOD_Q, + seed, + 2, + 1, + ) + # Encode 0; proof with incorrect plaintext runs but is invalid, even for valid range/encryption + message0 = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) + proof03bad = make_range_chaum_pedersen( + message0, nonce, keypair.public_key, ONE_MOD_Q, seed, 3, 10 + ) + self.assertFalse(proof03bad.is_valid(message0, keypair.public_key, ONE_MOD_Q)) + # Encode 2; proof is invalid when issued wrong encryption for validation + proof24 = make_range_chaum_pedersen( + message2, nonce, keypair.public_key, ONE_MOD_Q, seed, 2, 4 + ) + self.assertFalse(proof24.is_valid(message0, keypair.public_key, ONE_MOD_Q)) + + @settings( + deadline=timedelta(milliseconds=2000), + suppress_health_check=[HealthCheck.too_slow], + max_examples=10, + ) + @given(elgamal_keypairs(), elements_mod_q_no_zero(), elements_mod_q()) + def test_rcp_proof_zero( + self, keypair: ElGamalKeyPair, nonce: ElementModQ, seed: ElementModQ + ): + message = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) + proof = make_range_chaum_pedersen( + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 + ) + proof_bad = make_range_chaum_pedersen( + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 1 + ) + self.assertTrue(proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) + self.assertFalse(proof_bad.is_valid(message, keypair.public_key, ONE_MOD_Q)) + + @settings( + deadline=timedelta(milliseconds=2000), + suppress_health_check=[HealthCheck.too_slow], + max_examples=10, + ) + @given(elgamal_keypairs(), elements_mod_q_no_zero(), elements_mod_q()) + def test_rcp_proof_one( + self, keypair: ElGamalKeyPair, nonce: ElementModQ, seed: ElementModQ + ): + message = get_optional(elgamal_encrypt(1, nonce, keypair.public_key)) + proof = make_range_chaum_pedersen( + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 1 + ) + proof_bad = make_range_chaum_pedersen( + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 + ) + self.assertTrue(proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) + self.assertFalse(proof_bad.is_valid(message, keypair.public_key, ONE_MOD_Q)) + + @settings( + deadline=timedelta(milliseconds=2000), + suppress_health_check=[HealthCheck.too_slow], + max_examples=10, + ) + @given(elgamal_keypairs(), elements_mod_q_no_zero(), elements_mod_q()) + def test_rcp_proof_broken( + self, keypair: ElGamalKeyPair, nonce: ElementModQ, seed: ElementModQ + ): + # Verify two different ways to generate an invalid range C-P proof + message = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) + message_bad = get_optional(elgamal_encrypt(2, nonce, keypair.public_key)) + proof = make_range_chaum_pedersen( + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 + ) + proof_bad = make_range_chaum_pedersen( + message_bad, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 + ) + self.assertFalse(proof_bad.is_valid(message_bad, keypair.public_key, ONE_MOD_Q)) + self.assertFalse(proof.is_valid(message_bad, keypair.public_key, ONE_MOD_Q)) + + class TestDisjunctiveChaumPedersen(BaseTestCase): - """Disjunctive Chaum Pedersen tests""" + """Disjunctive Chaum-Pedersen tests""" def test_djcp_proofs_simple(self): # doesn't get any simpler than this @@ -130,7 +266,7 @@ def test_djcp_proof_broken( class TestChaumPedersen(BaseTestCase): - """Chaum Pedersen tests""" + """Chaum-Pedersen tests""" def test_cp_proofs_simple(self): keypair = elgamal_keypair_from_secret(TWO_MOD_Q) @@ -139,10 +275,10 @@ def test_cp_proofs_simple(self): message = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) decryption = message.partial_decrypt(keypair.secret_key) proof = make_chaum_pedersen( - message, keypair.secret_key, decryption, seed, ONE_MOD_Q + message, keypair.secret_key, ONE_MOD_Q, seed, decryption ) bad_proof = make_chaum_pedersen( - message, keypair.secret_key, TWO_MOD_P, seed, ONE_MOD_Q + message, keypair.secret_key, ONE_MOD_Q, seed, TWO_MOD_P ) self.assertTrue( proof.is_valid(message, keypair.public_key, decryption, ONE_MOD_Q) @@ -178,10 +314,10 @@ def test_cp_proof( message = get_optional(elgamal_encrypt(constant, nonce, keypair.public_key)) decryption = message.partial_decrypt(keypair.secret_key) proof = make_chaum_pedersen( - message, keypair.secret_key, decryption, seed, ONE_MOD_Q + message, keypair.secret_key, ONE_MOD_Q, seed, decryption ) bad_proof = make_chaum_pedersen( - message, keypair.secret_key, int_to_p(bad_constant), seed, ONE_MOD_Q + message, keypair.secret_key, ONE_MOD_Q, seed, int_to_p(bad_constant) ) self.assertTrue( proof.is_valid(message, keypair.public_key, decryption, ONE_MOD_Q) @@ -192,7 +328,7 @@ def test_cp_proof( class TestConstantChaumPedersen(BaseTestCase): - """Constant Chaum Pedersen tests""" + """Constant Chaum-Pedersen tests""" def test_ccp_proofs_simple_encryption_of_zero(self): keypair = elgamal_keypair_from_secret(TWO_MOD_Q) @@ -200,10 +336,10 @@ def test_ccp_proofs_simple_encryption_of_zero(self): seed = TWO_MOD_Q message = get_optional(elgamal_encrypt(0, nonce, keypair.public_key)) proof = make_constant_chaum_pedersen( - message, 0, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 ) bad_proof = make_constant_chaum_pedersen( - message, 1, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 1 ) self.assertTrue(proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) self.assertFalse(bad_proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) @@ -214,10 +350,10 @@ def test_ccp_proofs_simple_encryption_of_one(self): seed = TWO_MOD_Q message = get_optional(elgamal_encrypt(1, nonce, keypair.public_key)) proof = make_constant_chaum_pedersen( - message, 1, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 1 ) bad_proof = make_constant_chaum_pedersen( - message, 0, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, 0 ) self.assertTrue(proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) self.assertFalse(bad_proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) @@ -253,19 +389,19 @@ def test_ccp_proof( ) proof = make_constant_chaum_pedersen( - message, constant, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, constant ) self.assertTrue(proof.is_valid(message, keypair.public_key, ONE_MOD_Q)) proof_bad1 = make_constant_chaum_pedersen( - message_bad, constant, nonce, keypair.public_key, seed, ONE_MOD_Q + message_bad, nonce, keypair.public_key, ONE_MOD_Q, seed, constant ) self.assertFalse( proof_bad1.is_valid(message_bad, keypair.public_key, ONE_MOD_Q) ) proof_bad2 = make_constant_chaum_pedersen( - message, bad_constant, nonce, keypair.public_key, seed, ONE_MOD_Q + message, nonce, keypair.public_key, ONE_MOD_Q, seed, bad_constant ) self.assertFalse(proof_bad2.is_valid(message, keypair.public_key, ONE_MOD_Q)) diff --git a/tests/property/test_decrypt_with_secrets.py b/tests/property/test_decrypt_with_secrets.py index 4dff817d..c1d722cf 100644 --- a/tests/property/test_decrypt_with_secrets.py +++ b/tests/property/test_decrypt_with_secrets.py @@ -9,7 +9,7 @@ from tests.base_test_case import BaseTestCase -from electionguard.chaum_pedersen import DisjunctiveChaumPedersenProof +from electionguard.chaum_pedersen import RangeChaumPedersenProof from electionguard.decrypt_with_secrets import ( decrypt_selection_with_secret, decrypt_selection_with_nonce, @@ -33,8 +33,6 @@ from electionguard.manifest import ( ContestDescription, SelectionDescription, - generate_placeholder_selections_from, - contest_description_with_placeholders_from, ) import electionguard_tools.factories.ballot_factory as BallotFactory @@ -142,19 +140,16 @@ def test_decrypt_selection_valid_input_tampered_fails( # tamper with the proof malformed_proof = deepcopy(subject) - altered_a0 = mult_p(subject.proof.proof_zero_pad, TWO_MOD_P) - malformed_disjunctive = DisjunctiveChaumPedersenProof( - altered_a0, - malformed_proof.proof.proof_zero_data, - malformed_proof.proof.proof_one_pad, - malformed_proof.proof.proof_one_data, - malformed_proof.proof.proof_zero_challenge, - malformed_proof.proof.proof_one_challenge, - malformed_proof.proof.challenge, - malformed_proof.proof.proof_zero_response, - malformed_proof.proof.proof_one_response, - ) - malformed_proof.proof = malformed_disjunctive + malformed_commitments = malformed_proof.proof.commitments + malformed_commitments[0] = mult_p( + malformed_proof.proof.commitments[0], TWO_MOD_P + ) + malformed_range = RangeChaumPedersenProof( + malformed_commitments, + malformed_proof.proof.challenges, + malformed_proof.proof.responses, + ) + malformed_proof.proof = malformed_range result_from_key_malformed_encryption = decrypt_selection_with_secret( malformed_encryption, @@ -209,7 +204,10 @@ def test_decrypt_selection_tampered_nonce_fails( # Arrange random = Random(random_seed) _, description = selection_description - data = ballot_factory.get_random_selection_from(description, random) + selection_limit = 2 + data = ballot_factory.get_random_selection_from( + description, random, selection_limit + ) # Act subject = encrypt_selection( @@ -218,6 +216,7 @@ def test_decrypt_selection_tampered_nonce_fails( keypair.public_key, ONE_MOD_Q, nonce_seed, + selection_limit, should_verify_proofs=True, ) self.assertIsNotNone(subject) @@ -258,19 +257,12 @@ def test_decrypt_contest_valid_input_succeeds( random = Random(random_seed) data = ballot_factory.get_random_contest_from(description, random) - placeholders = generate_placeholder_selections_from( - description, description.number_elected - ) - description_with_placeholders = contest_description_with_placeholders_from( - description, placeholders - ) - - self.assertTrue(description_with_placeholders.is_valid()) + self.assertTrue(description.is_valid()) # Act subject = encrypt_contest( data, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, nonce_seed, @@ -278,30 +270,26 @@ def test_decrypt_contest_valid_input_succeeds( ) self.assertIsNotNone(subject) - # Decrypt the contest, but keep the placeholders - # so we can verify the selection count matches as expected in the test + # Decrypt the contest result_from_key = decrypt_contest_with_secret( subject, - description_with_placeholders, + description, keypair.public_key, keypair.secret_key, ONE_MOD_Q, - remove_placeholders=False, ) result_from_nonce = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, - remove_placeholders=False, ) result_from_nonce_seed = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, nonce_seed, - remove_placeholders=False, ) # Assert @@ -310,32 +298,29 @@ def test_decrypt_contest_valid_input_succeeds( self.assertIsNotNone(result_from_nonce_seed) # The decrypted contest should include an entry for each possible selection - # and placeholders for each seat - expected_entries = ( - len(description.ballot_selections) + description.number_elected - ) + expected_entries = len(description.ballot_selections) self.assertTrue( result_from_key.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) self.assertTrue( result_from_nonce.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) self.assertTrue( result_from_nonce_seed.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) @@ -352,7 +337,7 @@ def test_decrypt_contest_valid_input_succeeds( self.assertEqual(key_selected, nonce_selected) self.assertEqual(seed_selected, nonce_selected) - self.assertEqual(description.number_elected, key_selected) + self.assertGreaterEqual(description.votes_allowed, key_selected) # Assert each selection is valid for selection_description in description.ballot_selections: @@ -386,9 +371,6 @@ def test_decrypt_contest_valid_input_succeeds( self.assertTrue(data_selections_exist[0].vote == nonce_selection.vote) self.assertTrue(data_selections_exist[0].vote == seed_selection.vote) - # TODO: also check edge cases such as: - # - placeholder selections are true for under votes - self.assertTrue(key_selection.is_valid(selection_description.object_id)) self.assertTrue(nonce_selection.is_valid(selection_description.object_id)) self.assertTrue(seed_selection.is_valid(selection_description.object_id)) @@ -419,19 +401,12 @@ def test_decrypt_contest_invalid_input_fails( random = Random(random_seed) data = ballot_factory.get_random_contest_from(description, random) - placeholders = generate_placeholder_selections_from( - description, description.number_elected - ) - description_with_placeholders = contest_description_with_placeholders_from( - description, placeholders - ) - - self.assertTrue(description_with_placeholders.is_valid()) + self.assertTrue(description.is_valid()) # Act subject = encrypt_contest( data, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, nonce_seed, @@ -443,18 +418,16 @@ def test_decrypt_contest_invalid_input_fails( result_from_nonce = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, - remove_placeholders=False, ) result_from_nonce_seed = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, nonce_seed, - remove_placeholders=False, ) # Assert @@ -468,26 +441,23 @@ def test_decrypt_contest_invalid_input_fails( result_from_key_tampered = decrypt_contest_with_secret( subject, - description_with_placeholders, + description, keypair.public_key, keypair.secret_key, ONE_MOD_Q, - remove_placeholders=False, ) result_from_nonce_tampered = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, - remove_placeholders=False, ) result_from_nonce_seed_tampered = decrypt_contest_with_nonce( subject, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, nonce_seed, - remove_placeholders=False, ) # Assert @@ -531,14 +501,12 @@ def test_decrypt_ballot_valid_input_succeeds(self, keypair: ElGamalKeyPair): context.crypto_extended_base_hash, keypair.public_key, keypair.secret_key, - remove_placeholders=False, ) result_from_nonce = decrypt_ballot_with_nonce( subject, internal_manifest, context.crypto_extended_base_hash, keypair.public_key, - remove_placeholders=False, ) result_from_nonce_seed = decrypt_ballot_with_nonce( subject, @@ -546,7 +514,6 @@ def test_decrypt_ballot_valid_input_succeeds(self, keypair: ElGamalKeyPair): context.crypto_extended_base_hash, keypair.public_key, subject.nonce, - remove_placeholders=False, ) # Assert @@ -559,11 +526,7 @@ def test_decrypt_ballot_valid_input_succeeds(self, keypair: ElGamalKeyPair): self.assertEqual(data.object_id, result_from_nonce_seed.object_id) for description in internal_manifest.get_contests_for(data.style_id): - - expected_entries = ( - len(description.ballot_selections) + description.number_elected - ) - + expected_entries = len(description.ballot_selections) key_contest = [ contest for contest in result_from_key.contests @@ -595,24 +558,24 @@ def test_decrypt_ballot_valid_input_succeeds(self, keypair: ElGamalKeyPair): key_contest.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) self.assertTrue( nonce_contest.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) self.assertTrue( seed_contest.is_valid( description.object_id, expected_entries, - description.number_elected, description.votes_allowed, + description.votes_allowed_per_selection, ) ) @@ -652,9 +615,6 @@ def test_decrypt_ballot_valid_input_succeeds(self, keypair: ElGamalKeyPair): else: data_selection = None - # TODO: also check edge cases such as: - # - placeholder selections are true for under votes - self.assertTrue(key_selection.is_valid(selection_description.object_id)) self.assertTrue( nonce_selection.is_valid(selection_description.object_id) diff --git a/tests/property/test_encrypt.py b/tests/property/test_encrypt.py index 6e8a20ae..c3431f50 100644 --- a/tests/property/test_encrypt.py +++ b/tests/property/test_encrypt.py @@ -20,10 +20,8 @@ from electionguard.constants import get_small_prime from electionguard.chaum_pedersen import ( - ConstantChaumPedersenProof, - DisjunctiveChaumPedersenProof, - make_constant_chaum_pedersen, - make_disjunctive_chaum_pedersen, + RangeChaumPedersenProof, + make_range_chaum_pedersen, ) from electionguard.elgamal import ( ElGamalKeyPair, @@ -49,8 +47,6 @@ ) from electionguard.manifest import ( ContestDescription, - contest_description_with_placeholders_from, - generate_placeholder_selections_from, SelectionDescription, VoteVariationType, ) @@ -220,19 +216,16 @@ def test_encrypt_selection_valid_input_tampered_encryption_fails( # tamper with the proof malformed_proof = deepcopy(result) - altered_a0 = mult_p(result.proof.proof_zero_pad, TWO_MOD_P) - malformed_disjunctive = DisjunctiveChaumPedersenProof( - altered_a0, - malformed_proof.proof.proof_zero_data, - malformed_proof.proof.proof_one_pad, - malformed_proof.proof.proof_one_data, - malformed_proof.proof.proof_zero_challenge, - malformed_proof.proof.proof_one_challenge, - malformed_proof.proof.challenge, - malformed_proof.proof.proof_zero_response, - malformed_proof.proof.proof_one_response, - ) - malformed_proof.proof = malformed_disjunctive + malformed_commitments = malformed_proof.proof.commitments + malformed_commitments[0] = mult_p( + malformed_proof.proof.commitments[0], TWO_MOD_P + ) + malformed_range = RangeChaumPedersenProof( + malformed_commitments, + malformed_proof.proof.challenges, + malformed_proof.proof.responses, + ) + malformed_proof.proof = malformed_range # Assert self.assertFalse( @@ -289,10 +282,7 @@ def test_encrypt_contest_valid_input_succeeds( ) # The encrypted contest should include an entry for each possible selection - # and placeholders for each seat - expected_entries = ( - len(description.ballot_selections) + description.number_elected - ) + expected_entries = len(description.ballot_selections) self.assertEqual(len(result.ballot_selections), expected_entries) @settings( @@ -331,15 +321,15 @@ def test_encrypt_contest_valid_input_tampered_proof_fails( # tamper with the proof malformed_proof = deepcopy(result) - altered_a = mult_p(result.proof.pad, TWO_MOD_P) - malformed_disjunctive = ConstantChaumPedersenProof( + altered_a = mult_p(result.proof.commitments[0], TWO_MOD_P) + malformed_pad = RangeChaumPedersenProof( altered_a, - malformed_proof.proof.data, - malformed_proof.proof.challenge, - malformed_proof.proof.response, - malformed_proof.proof.constant, + malformed_proof.proof.commitments[1], + malformed_proof.proof.challenges[0], + malformed_proof.proof.responses[0], + malformed_proof.proof.limit, ) - malformed_proof.proof = malformed_disjunctive + malformed_proof.proof = malformed_pad # remove the proof missing_proof = deepcopy(result) @@ -403,8 +393,8 @@ def test_encrypt_contest_manually_formed_contest_description_valid_succeeds(self electoral_district_id="0@A.com-gp-unit", sequence_order=1, vote_variation=VoteVariationType.n_of_m, - number_elected=1, votes_allowed=1, + votes_allowed_per_selection=1, name="", ballot_selections=[ SelectionDescription( @@ -424,17 +414,51 @@ def test_encrypt_contest_manually_formed_contest_description_valid_succeeds(self #################### data = ballot_factory.get_random_contest_from(description, Random(0)) - placeholders = generate_placeholder_selections_from( - description, description.number_elected + # Act + subject = encrypt_contest( + data, + description, + keypair.public_key, + ONE_MOD_Q, + seed, + should_verify_proofs=True, ) - description_with_placeholders = contest_description_with_placeholders_from( - description, placeholders + self.assertIsNotNone(subject) + + def test_encrypt_contest_manually_formed_cumulative_contest_description_valid_succeeds( + self, + ): + description = ContestDescription( + object_id="0@A.com-contest", + electoral_district_id="0@A.com-gp-unit", + sequence_order=1, + vote_variation=VoteVariationType.cumulative, + votes_allowed=4, + votes_allowed_per_selection=2, + name="", + ballot_selections=[ + SelectionDescription( + "0@A.com-selection", + 0, + "0@A.com", + ), + SelectionDescription("0@B.com-selection", 1, "0@B.com"), + SelectionDescription("0@C.com-selection", 2, "0@C.com"), + ], + ballot_title=None, + ballot_subtitle=None, ) + keypair = elgamal_keypair_from_secret(TWO_MOD_Q) + seed = ONE_MOD_Q + + #################### + data = ballot_factory.get_random_contest_from(description, Random(0)) + # Act subject = encrypt_contest( data, - description_with_placeholders, + description, keypair.public_key, ONE_MOD_Q, seed, @@ -453,8 +477,8 @@ def test_encrypt_contest_duplicate_selection_object_ids_fails(self): electoral_district_id="0@A.com-gp-unit", sequence_order=1, vote_variation=VoteVariationType.n_of_m, - number_elected=1, votes_allowed=1, + votes_allowed_per_selection=1, name="", ballot_selections=[ SelectionDescription( @@ -479,16 +503,9 @@ def test_encrypt_contest_duplicate_selection_object_ids_fails(self): description, Random(0), suppress_validity_check=True ) - placeholders = generate_placeholder_selections_from( - description, description.number_elected - ) - description_with_placeholders = contest_description_with_placeholders_from( - description, placeholders - ) - # Act subject = encrypt_contest( - data, description_with_placeholders, keypair.public_key, ONE_MOD_Q, seed + data, description, keypair.public_key, ONE_MOD_Q, seed ) self.assertIsNone(subject) @@ -681,33 +698,19 @@ def test_encrypt_ballot_with_derivative_nonces_regenerates_valid_proofs( *[selection.nonce for selection in contest.ballot_selections] ) - regenerated_constant = make_constant_chaum_pedersen( - elgamal_accumulation, - contest_description.number_elected, - aggregate_nonce, - keypair.public_key, - add_q(contest.nonce, TWO_MOD_Q), - context.crypto_extended_base_hash, - ) - - self.assertTrue( - regenerated_constant.is_valid( - elgamal_accumulation, - keypair.public_key, - context.crypto_extended_base_hash, - ) - ) - + # Track plaintext votes to regenerate range proof for contest sum + total_votes = 0 for selection in contest.ballot_selections: # Since we know the nonce, we can decrypt the plaintext representation = selection.ciphertext.decrypt_known_nonce( keypair.public_key, selection.nonce ) + total_votes += representation # one could also decrypt with the secret key: # representation = selection.message.decrypt(keypair.secret_key) - regenerated_disjuctive = make_disjunctive_chaum_pedersen( + regenerated_selection_range = make_range_chaum_pedersen( selection.ciphertext, selection.nonce, keypair.public_key, @@ -717,13 +720,31 @@ def test_encrypt_ballot_with_derivative_nonces_regenerates_valid_proofs( ) self.assertTrue( - regenerated_disjuctive.is_valid( + regenerated_selection_range.is_valid( selection.ciphertext, keypair.public_key, context.crypto_extended_base_hash, ) ) + regenerated_contest_range = make_range_chaum_pedersen( + elgamal_accumulation, + aggregate_nonce, + keypair.public_key, + context.crypto_extended_base_hash, + add_q(contest.nonce, TWO_MOD_Q), + plaintext=total_votes, + limit=contest_description.votes_allowed, + ) + + self.assertTrue( + regenerated_contest_range.is_valid( + elgamal_accumulation, + keypair.public_key, + context.crypto_extended_base_hash, + ) + ) + def test_encrypt_ballot_with_verify_proofs_false_passed_on(self): """ This test is for https://github.com/microsoft/electionguard-python/issues/459 diff --git a/tests/property/test_encrypt_hypotheses.py b/tests/property/test_encrypt_hypotheses.py index da14e9a4..f7fa8e1f 100644 --- a/tests/property/test_encrypt_hypotheses.py +++ b/tests/property/test_encrypt_hypotheses.py @@ -128,16 +128,11 @@ def test_accumulation_encryption_decryption( for contest in internal_manifest.contests: # Sanity check the generated data self.assertTrue(len(contest.ballot_selections) > 0) - self.assertTrue(len(contest.placeholder_selections) > 0) decrypted_selection_tallies = [ decrypted_tallies[selection.object_id] for selection in contest.ballot_selections ] - decrypted_placeholder_tallies = [ - decrypted_tallies[placeholder.object_id] - for placeholder in contest.placeholder_selections - ] plaintext_tally_values = [ plaintext_tallies[selection.object_id] for selection in contest.ballot_selections @@ -146,10 +141,10 @@ def test_accumulation_encryption_decryption( # verify the plaintext tallies match the decrypted tallies self.assertEqual(decrypted_selection_tallies, plaintext_tally_values) - # validate the right number of selections including placeholders across all ballots - self.assertEqual( - contest.number_elected * num_ballots, - sum(decrypted_selection_tallies) + sum(decrypted_placeholder_tallies), + # validate the number of selections across all ballots + self.assertGreaterEqual( + contest.votes_allowed * num_ballots, + sum(decrypted_selection_tallies), ) @@ -163,8 +158,6 @@ def _accumulate_encrypted_ballots( `n_of_m` elections. It's not a general-purpose tallying mechanism for other election types. - Note that the output will include both "normal" and "placeholder" selections. - :param encrypted_zero: an encrypted zero, used for the accumulation :param ballots: a list of encrypted ballots :return: a dict from selection object_id's to `ElGamalCiphertext` totals diff --git a/tests/unit/electionguard/test_ballot.py b/tests/unit/electionguard/test_ballot.py index 328ffa82..8f2de8d2 100644 --- a/tests/unit/electionguard/test_ballot.py +++ b/tests/unit/electionguard/test_ballot.py @@ -1,7 +1,7 @@ from tests.base_test_case import BaseTestCase from electionguard.manifest import ( - ContestDescriptionWithPlaceholders, + ContestDescription, SelectionDescription, VoteVariationType, ) @@ -9,31 +9,26 @@ from electionguard.utils import NullVoteException, OverVoteException, UnderVoteException -NUMBER_ELECTED = 2 +VOTES_ALLOWED = 2 -def get_sample_contest_description() -> ContestDescriptionWithPlaceholders: +def get_sample_contest_description() -> ContestDescription: ballot_selections = [ SelectionDescription("option-1-id", 1, "luke-skywalker-id"), SelectionDescription("option-2-id", 2, "darth-vader-id"), SelectionDescription("option-3-id", 3, "obi-wan-kenobi-id"), ] - placeholder_selections = [ - SelectionDescription("placeholder-1-id", 4, "placeholder-id"), - SelectionDescription("placeholder-2-id", 5, "placeholder-id"), - ] - description = ContestDescriptionWithPlaceholders( + description = ContestDescription( "favorite-character-id", 1, "dagobah-id", - VoteVariationType.n_of_m, - NUMBER_ELECTED, - None, "favorite-star-wars-character", + VoteVariationType.n_of_m, + VOTES_ALLOWED, + 1, ballot_selections, None, None, - placeholder_selections, ) return description @@ -47,11 +42,13 @@ def test_contest_valid(self) -> None: contest = contest_from(contest_description) # Add Votes - for i in range(NUMBER_ELECTED): + for i in range(VOTES_ALLOWED): contest.ballot_selections[i].vote = 1 # Act & Assert. try: + print(contest_description.votes_allowed) + print(contest_description.votes_allowed_per_selection) contest.valid(contest_description) except (NullVoteException, OverVoteException, UnderVoteException): self.fail("No exceptions should be thrown.") @@ -71,7 +68,7 @@ def test_contest_valid_with_under_vote(self) -> None: under_vote = contest_from(contest_description) # Add Votes - for i in range(NUMBER_ELECTED - 1): + for i in range(VOTES_ALLOWED - 1): under_vote.ballot_selections[i].vote = 1 # Act & Assert. @@ -84,7 +81,7 @@ def test_contest_valid_with_over_vote(self) -> None: over_vote = contest_from(contest_description) # Add Votes - for i in range(NUMBER_ELECTED + 1): + for i in range(VOTES_ALLOWED + 1): over_vote.ballot_selections[i].vote = 1 # Act & Assert. diff --git a/tests/unit/electionguard/test_encrypt.py b/tests/unit/electionguard/test_encrypt.py index 42471ab9..c23a4079 100644 --- a/tests/unit/electionguard/test_encrypt.py +++ b/tests/unit/electionguard/test_encrypt.py @@ -18,13 +18,13 @@ from electionguard.manifest import ( SelectionDescription, VoteVariationType, - ContestDescriptionWithPlaceholders, + ContestDescription, ) from electionguard.serialize import to_raw from electionguard.utils import get_optional -def get_sample_contest_description() -> ContestDescriptionWithPlaceholders: +def get_sample_contest_description() -> ContestDescription: ballot_selections = [ SelectionDescription( "some-object-id-affirmative", 0, "some-candidate-id-affirmative" @@ -33,23 +33,17 @@ def get_sample_contest_description() -> ContestDescriptionWithPlaceholders: "some-object-id-negative", 1, "some-candidate-id-negative" ), ] - placeholder_selections = [ - SelectionDescription( - "some-object-id-placeholder", 2, "some-candidate-id-placeholder" - ) - ] - metadata = ContestDescriptionWithPlaceholders( + metadata = ContestDescription( "some-contest-object-id", 0, "some-electoral-district-id", + "some-referendum-contest-name", VoteVariationType.one_of_m, 1, 1, - "some-referendum-contest-name", ballot_selections, None, None, - placeholder_selections, ) return metadata diff --git a/tests/unit/electionguard/test_manifest.py b/tests/unit/electionguard/test_manifest.py index c4c0e488..d12e7988 100644 --- a/tests/unit/electionguard/test_manifest.py +++ b/tests/unit/electionguard/test_manifest.py @@ -6,7 +6,6 @@ from electionguard.manifest import ( Candidate, ContestDescription, - ContestDescriptionWithPlaceholders, InternationalizedText, Language, Manifest, @@ -187,13 +186,13 @@ def test_manifest_from_file_generates_consistent_internal_description_contest_ha self.assertEqual(expected.crypto_hash(), actual.crypto_hash()) def test_contest_description_valid_input_succeeds(self) -> None: - description = ContestDescriptionWithPlaceholders( + description = ContestDescription( object_id="0@A.com-contest", electoral_district_id="0@A.com-gp-unit", sequence_order=1, vote_variation=VoteVariationType.n_of_m, - number_elected=1, votes_allowed=1, + votes_allowed_per_selection=1, name="", ballot_selections=[ SelectionDescription( @@ -209,26 +208,19 @@ def test_contest_description_valid_input_succeeds(self) -> None: ], ballot_title=None, ballot_subtitle=None, - placeholder_selections=[ - SelectionDescription( - "0@A.com-contest-2-placeholder", - 2, - "0@A.com-contest-2-candidate", - ) - ], ) self.assertTrue(description.is_valid()) def test_contest_description_invalid_input_fails(self) -> None: - description = ContestDescriptionWithPlaceholders( + description = ContestDescription( object_id="0@A.com-contest", electoral_district_id="0@A.com-gp-unit", sequence_order=1, vote_variation=VoteVariationType.n_of_m, - number_elected=1, votes_allowed=1, + votes_allowed_per_selection=1, name="", ballot_selections=[ SelectionDescription( @@ -245,13 +237,6 @@ def test_contest_description_invalid_input_fails(self) -> None: ], ballot_title=None, ballot_subtitle=None, - placeholder_selections=[ - SelectionDescription( - "0@A.com-contest-2-placeholder", - 2, - "0@A.com-contest-2-candidate", - ) - ], ) self.assertFalse(description.is_valid())