Skip to content

Commit 355b587

Browse files
authored
✨ Implement Compact Ballot (#331)
* ✨ Implement Compact Ballot - Add Compact Plaintext and Ciphertext Ballot - Update Encryption Device and hashing mechanisms - Isolate `create_ballot_hash` to get a single ballot hash - Isolate `encrypt_ballot_contests` for using to encrypt all the ballot contests - Add Compress Methods * 🚧 Update for Changes in EncryptionDevice 🔨Fix Tracker Test Squish * ⚗️ Add Compact Ballot Tests * ♻️ Refactor ElectionFactory to have Encryption Device
1 parent ccdc1a7 commit 355b587

16 files changed

Lines changed: 398 additions & 60 deletions

docs/2_Encrypt_Ballots.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ context: CiphertextElectionContext
2828
ballot: PlaintextBallot
2929

3030
# Configure an encryption device
31-
device = EncryptionDevice("polling-place-one")
31+
device = EncryptionDevice(generate_device_uuid(), "Session", 12345, "polling-place-one")
3232
encrypter = EncryptionMediator(internal_manifest, context, device)
3333

3434
# Encrypt the ballot

src/electionguard/ballot.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,7 @@ def make_ciphertext_ballot(
871871
if len(contests) == 0:
872872
log_warning("ciphertext ballot with no contests")
873873

874-
contest_hashes = [contest.crypto_hash for contest in contests]
875-
contest_hash = hash_elems(object_id, manifest_hash, *contest_hashes)
874+
contest_hash = create_ballot_hash(object_id, manifest_hash, contests)
876875

877876
timestamp = to_ticks(datetime.now()) if timestamp is None else timestamp
878877
if previous_ballot_code is None:
@@ -895,6 +894,17 @@ def make_ciphertext_ballot(
895894
)
896895

897896

897+
def create_ballot_hash(
898+
ballot_id: str,
899+
description_hash: ElementModQ,
900+
contests: List[CiphertextBallotContest],
901+
) -> ElementModQ:
902+
"""Create the hash of the ballot contests"""
903+
904+
contest_hashes = [contest.crypto_hash for contest in contests]
905+
return hash_elems(ballot_id, description_hash, *contest_hashes)
906+
907+
898908
def make_ciphertext_submitted_ballot(
899909
object_id: str,
900910
style_id: str,

src/electionguard/ballot_code.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
from .group import ElementModQ
33

44

5-
def get_hash_for_device(uuid: int, location: str) -> ElementModQ:
5+
def get_hash_for_device(
6+
uuid: int, session_id: str, launch_code: int, location: str
7+
) -> ElementModQ:
68
"""
79
Get starting hash for given device
810
:param uuid: Unique identifier of device
911
:param location: Location of device
1012
:return: Starting hash of device
1113
"""
12-
return hash_elems(uuid, location)
14+
return hash_elems(uuid, session_id, launch_code, location)
1315

1416

1517
def get_rotating_ballot_code(
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from dataclasses import dataclass
2+
from typing import Dict, List
3+
4+
5+
from .ballot import (
6+
SubmittedBallot,
7+
ExtendedData,
8+
PlaintextBallot,
9+
PlaintextBallotContest,
10+
PlaintextBallotSelection,
11+
create_ballot_hash,
12+
)
13+
from .ballot_box import BallotBoxState
14+
from .election import CiphertextElectionContext
15+
from .encrypt import encrypt_ballot_contests
16+
from .group import ElementModQ
17+
from .manifest import (
18+
ContestDescriptionWithPlaceholders,
19+
InternalManifest,
20+
)
21+
from .utils import get_optional
22+
23+
YES_VOTE = 1
24+
NO_VOTE = 0
25+
26+
27+
@dataclass
28+
class CompactPlaintextBallot:
29+
"""A compact plaintext representation of ballot minimized for data size"""
30+
31+
object_id: str
32+
style_id: str
33+
selections: List[bool]
34+
extended_data: Dict[int, ExtendedData]
35+
36+
37+
@dataclass
38+
class CompactSubmittedBallot:
39+
"""A compact submitted ballot minimized for data size"""
40+
41+
compact_plaintext_ballot: CompactPlaintextBallot
42+
timestamp: int
43+
ballot_nonce: ElementModQ
44+
previous_code: ElementModQ
45+
code: ElementModQ
46+
ballot_box_state: BallotBoxState
47+
48+
49+
def compress_plaintext_ballot(ballot: PlaintextBallot) -> CompactPlaintextBallot:
50+
"""Compress a plaintext ballot into a compact plaintext ballot"""
51+
selections = _get_compact_selections(ballot)
52+
extended_data = _get_compact_extended_data(ballot)
53+
54+
return CompactPlaintextBallot(
55+
ballot.object_id, ballot.style_id, selections, extended_data
56+
)
57+
58+
59+
def compress_submitted_ballot(
60+
ballot: SubmittedBallot,
61+
plaintext_ballot: PlaintextBallot,
62+
ballot_nonce: ElementModQ,
63+
) -> CompactSubmittedBallot:
64+
"""Compress a submitted ballot into a compact submitted ballot"""
65+
return CompactSubmittedBallot(
66+
compress_plaintext_ballot(plaintext_ballot),
67+
ballot.timestamp,
68+
ballot_nonce,
69+
ballot.previous_code,
70+
get_optional(ballot.code),
71+
ballot.state,
72+
)
73+
74+
75+
def expand_compact_submitted_ballot(
76+
compact_ballot: CompactSubmittedBallot,
77+
internal_manifest: InternalManifest,
78+
context: CiphertextElectionContext,
79+
) -> SubmittedBallot:
80+
"""
81+
Expand a compact submitted ballot using context and
82+
the election manifest into a submitted ballot
83+
"""
84+
# Expand ballot and encrypt & hash contests
85+
plaintext_ballot = expand_compact_plaintext_ballot(
86+
compact_ballot.compact_plaintext_ballot, internal_manifest
87+
)
88+
contests = get_optional(
89+
encrypt_ballot_contests(
90+
plaintext_ballot, internal_manifest, context, compact_ballot.ballot_nonce
91+
)
92+
)
93+
crypto_hash = create_ballot_hash(
94+
plaintext_ballot.object_id, internal_manifest.manifest_hash, contests
95+
)
96+
97+
return SubmittedBallot(
98+
plaintext_ballot.object_id,
99+
plaintext_ballot.style_id,
100+
internal_manifest.manifest_hash,
101+
compact_ballot.previous_code,
102+
contests,
103+
compact_ballot.code,
104+
compact_ballot.timestamp,
105+
crypto_hash,
106+
compact_ballot.ballot_nonce,
107+
compact_ballot.ballot_box_state,
108+
)
109+
110+
111+
def expand_compact_plaintext_ballot(
112+
compact_ballot: CompactPlaintextBallot, internal_manifest: InternalManifest
113+
) -> PlaintextBallot:
114+
"""Expand a compact plaintext ballot into the original plaintext ballot"""
115+
return PlaintextBallot(
116+
compact_ballot.object_id,
117+
compact_ballot.style_id,
118+
_get_plaintext_contests(compact_ballot, internal_manifest),
119+
)
120+
121+
122+
def _get_compact_selections(ballot: PlaintextBallot) -> List[bool]:
123+
selections = []
124+
for contest in ballot.contests:
125+
for selection in contest.ballot_selections:
126+
selections.append(selection.vote == YES_VOTE)
127+
return selections
128+
129+
130+
def _get_compact_extended_data(ballot: PlaintextBallot) -> Dict[int, ExtendedData]:
131+
extended_data = {}
132+
index = 0
133+
for contest in ballot.contests:
134+
for selection in contest.ballot_selections:
135+
index += 1
136+
if selection.extended_data:
137+
extended_data[index] = selection.extended_data
138+
return extended_data
139+
140+
141+
def _get_plaintext_contests(
142+
compact_ballot: CompactPlaintextBallot, internal_manifest: InternalManifest
143+
) -> List[PlaintextBallotContest]:
144+
"""Get ballot contests from compact plaintext ballot"""
145+
index = 0
146+
ballot_style_contests = _get_ballot_style_contests(
147+
compact_ballot.style_id, internal_manifest
148+
)
149+
150+
contests: List[PlaintextBallotContest] = []
151+
for manifest_contest in sorted(
152+
internal_manifest.contests, key=lambda c: c.sequence_order
153+
):
154+
contest_in_style = (
155+
ballot_style_contests.get(manifest_contest.object_id) is not None
156+
)
157+
158+
# Iterate through selections. If contest not in style, mark placeholder
159+
selections: List[PlaintextBallotSelection] = []
160+
for selection in sorted(
161+
manifest_contest.ballot_selections, key=lambda s: s.sequence_order
162+
):
163+
selections.append(
164+
PlaintextBallotSelection(
165+
selection.object_id,
166+
YES_VOTE if compact_ballot.selections[index] else NO_VOTE,
167+
not contest_in_style,
168+
compact_ballot.extended_data.get(index),
169+
)
170+
)
171+
index += 1
172+
173+
contests.append(PlaintextBallotContest(manifest_contest.object_id, selections))
174+
return contests
175+
176+
177+
def _get_ballot_style_contests(
178+
ballot_style_id: str, internal_manifest: InternalManifest
179+
) -> Dict[str, ContestDescriptionWithPlaceholders]:
180+
ballot_style_contests = internal_manifest.get_contests_for(ballot_style_id)
181+
return {contest.object_id: contest for contest in ballot_style_contests}

src/electionguard/encrypt.py

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,40 @@ class EncryptionDevice(Serializable):
3535
"""
3636

3737
uuid: int
38+
"""Unique identifier for device"""
39+
40+
session_id: str
41+
"""Used to identify session and protect the timestamp"""
42+
43+
launch_code: int
44+
"""Election initialization value"""
45+
3846
location: str
47+
"""Arbitary string to designate the location of device"""
3948

40-
def __init__(self, location: str) -> None:
41-
self.uuid = generate_device_uuid()
49+
def __init__(
50+
self,
51+
uuid: int,
52+
session_id: str,
53+
launch_code: int,
54+
location: str,
55+
) -> None:
56+
self.uuid = uuid
57+
self.session_id = session_id
58+
self.launch_code = launch_code
4259
self.location = location
4360

4461
def get_hash(self) -> ElementModQ:
4562
"""
4663
Get hash for encryption device
4764
:return: Starting hash
4865
"""
49-
return get_hash_for_device(self.uuid, self.location)
66+
return get_hash_for_device(
67+
self.uuid, self.session_id, self.launch_code, self.location
68+
)
69+
70+
def get_timestamp(self) -> int:
71+
pass
5072

5173

5274
class EncryptionMediator:
@@ -420,30 +442,11 @@ def encrypt_ballot(
420442
random_master_nonce,
421443
)
422444

423-
encrypted_contests: List[CiphertextBallotContest] = list()
424-
425-
# only iterate on contests for this specific ballot style
426-
for description in internal_manifest.get_contests_for(ballot.style_id):
427-
use_contest = None
428-
for contest in ballot.contests:
429-
if contest.object_id == description.object_id:
430-
use_contest = contest
431-
break
432-
# no selections provided for the contest, so create a placeholder contest
433-
if not use_contest:
434-
use_contest = contest_from(description)
435-
436-
encrypted_contest = encrypt_contest(
437-
use_contest,
438-
description,
439-
context.elgamal_public_key,
440-
context.crypto_extended_base_hash,
441-
nonce_seed,
442-
)
443-
444-
if encrypted_contest is None:
445-
return None # log will have happened earlier
446-
encrypted_contests.append(get_optional(encrypted_contest))
445+
encrypted_contests = encrypt_ballot_contests(
446+
ballot, internal_manifest, context, nonce_seed
447+
)
448+
if encrypted_contests is None:
449+
return None
447450

448451
# Create the return object
449452
encrypted_ballot = make_ciphertext_ballot(
@@ -469,3 +472,38 @@ def encrypt_ballot(
469472
):
470473
return encrypted_ballot
471474
return None # log will have happened earlier
475+
476+
477+
def encrypt_ballot_contests(
478+
ballot: PlaintextBallot,
479+
description: InternalManifest,
480+
context: CiphertextElectionContext,
481+
nonce_seed: ElementModQ,
482+
) -> Optional[List[CiphertextBallotContest]]:
483+
"""Encrypt contests from a plaintext ballot with a specific style"""
484+
encrypted_contests: List[CiphertextBallotContest] = []
485+
486+
# Only iterate on contests for this specific ballot style
487+
for ballot_style_contest in description.get_contests_for(ballot.style_id):
488+
use_contest = None
489+
for contest in ballot.contests:
490+
if contest.object_id == ballot_style_contest.object_id:
491+
use_contest = contest
492+
break
493+
494+
# no selections provided for the contest, so create a placeholder contest
495+
if not use_contest:
496+
use_contest = contest_from(ballot_style_contest)
497+
498+
encrypted_contest = encrypt_contest(
499+
use_contest,
500+
ballot_style_contest,
501+
context.elgamal_public_key,
502+
context.crypto_extended_base_hash,
503+
nonce_seed,
504+
)
505+
506+
if encrypted_contest is None:
507+
return None
508+
encrypted_contests.append(get_optional(encrypted_contest))
509+
return encrypted_contests

src/electionguardtest/election_factory.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime
22
import os
3+
import uuid
34
from dataclasses import dataclass
45
from typing import TypeVar, Callable, Optional, Tuple, List
56

@@ -14,7 +15,7 @@
1415
from electionguard.ballot import PlaintextBallot
1516
from electionguard.election import CiphertextElectionContext, ElectionConstants
1617
from electionguard.election_builder import ElectionBuilder
17-
from electionguard.encrypt import contest_from
18+
from electionguard.encrypt import EncryptionDevice, contest_from, generate_device_uuid
1819
from electionguard.group import ElementModP, TWO_MOD_Q
1920
from electionguard.guardian import Guardian
2021
from electionguard.key_ceremony import CoefficientValidationSet
@@ -257,6 +258,15 @@ def _get_manifest_from_file(filename: str) -> Manifest:
257258

258259
return manifest
259260

261+
@staticmethod
262+
def get_encryption_device() -> EncryptionDevice:
263+
return EncryptionDevice(
264+
generate_device_uuid(),
265+
"Session",
266+
12345,
267+
f"polling-place-{str(uuid.uuid1())}",
268+
)
269+
260270

261271
@composite
262272
def get_selection_description_well_formed(

0 commit comments

Comments
 (0)