Skip to content

馃悶 Shared selection IDs across contests silently corrupt the batch tally聽#816

Open

Description

@FM-Agent-Organization

Is there an existing issue for this?

  • I have searched the existing issues

Current Behavior

Manifest.is_valid() accepts two contests that both use selection IDs "yes" and "no". Ballots encrypt and cast successfully, but tally_ballots() returns incorrect totals:

Contest Votes on both cast ballots Expected tally Actual tally
measure-1 yes yes: 2, no: 0 yes: 0, no: 2
measure-2 no yes: 0, no: 2 yes: 0, no: 2

batch_append() keys ciphertexts by selection ID and ballot ID, omitting contest ID. The second contest overwrites the first. _execute_accumulate() then applies the same total to both contests, without an error.

The same ambiguity in get_shares_for_selection() causes spoiled ballot b3 to fail decryption. Warnings are logged, and the ballot is omitted from the returned plaintext results.

Expected Behavior

Reject cross-contest duplicate selection IDs during manifest validation, or consistently identify selections by (contest_id, selection_id). Accepted ballots must not produce incorrect totals.

Steps To Reproduce

  1. Install electionguard 1.4.0 (or put src on PYTHONPATH).
  2. Save the script below as repro.py and run python repro.py. It uses the production group and runs in a few seconds.
Complete reproduction script
import logging
from datetime import datetime, timezone

from electionguard.ballot import BallotBoxState, PlaintextBallot, PlaintextBallotContest, PlaintextBallotSelection
from electionguard.ballot_box import BallotBox, get_ballots
from electionguard.data_store import DataStore
from electionguard.decryption_mediator import DecryptionMediator
from electionguard.election import make_ciphertext_election_context
from electionguard.encrypt import encrypt_ballot
from electionguard.group import ONE_MOD_Q
from electionguard.guardian import Guardian
from electionguard.key_ceremony import CeremonyDetails
from electionguard.key_ceremony_mediator import KeyCeremonyMediator
from electionguard.manifest import (
    BallotStyle, Candidate, ContestDescription, ElectionType, GeopoliticalUnit, InternalManifest,
    Manifest, ReportingUnitType, SelectionDescription, SpecVersion, VoteVariationType,
)
from electionguard.tally import tally_ballots

logging.disable(logging.INFO)  # keep the library's warnings, drop its INFO lines


def selection(object_id, sequence_order):
    return SelectionDescription(object_id, sequence_order, candidate_id=object_id)


def contest(object_id, sequence_order, selections, number_elected=1, votes_allowed=1):
    return ContestDescription(object_id, sequence_order, "district", VoteVariationType.one_of_m,
                              number_elected, votes_allowed, object_id, selections)


def make_manifest(contests, styles=None, units=("district",)):
    candidates = sorted({s.candidate_id for c in contests for s in c.ballot_selections})
    return Manifest(
        "scope", SpecVersion.EG1_0, ElectionType.general,
        datetime(2020, 3, 1, tzinfo=timezone.utc), datetime(2020, 3, 2, tzinfo=timezone.utc),
        [GeopoliticalUnit(u, u, ReportingUnitType.county) for u in units], [],
        [Candidate(c) for c in candidates], contests,
        styles or [BallotStyle("style", ["district"])],
    )


def ballot(object_id, marks, style_id="style"):
    """marks: {contest_id: [selected selection ids]}"""
    return PlaintextBallot(object_id, style_id, [
        PlaintextBallotContest(c, [PlaintextBallotSelection(s, 1) for s in sel]) for c, sel in marks.items()])


def run_election(manifest, ballots, spoil=()):
    """One guardian, quorum 1: key ceremony, encrypt, cast or spoil, tally, decrypt."""
    print("manifest.is_valid():", manifest.is_valid())
    guardian = Guardian.from_nonce("guardian-1", 1, 1, 1)
    key_mediator = KeyCeremonyMediator("key-mediator", CeremonyDetails(1, 1))
    key_mediator.announce(guardian.share_key())
    joint_key = key_mediator.publish_joint_key()
    internal = InternalManifest(manifest)
    context = make_ciphertext_election_context(
        1, 1, joint_key.joint_public_key, joint_key.commitment_hash, manifest.crypto_hash())

    store = DataStore()
    box = BallotBox(internal, context, store)
    for plain in ballots:
        encrypted = encrypt_ballot(plain, internal, context, ONE_MOD_Q, should_verify_proofs=True)
        if encrypted is None:
            print(f"{plain.object_id}: encrypt_ballot returned None")
            continue
        submitted = box.spoil(encrypted) if plain.object_id in spoil else box.cast(encrypted)
        print(f"{plain.object_id}: {'spoil' if plain.object_id in spoil else 'cast'} ->",
              None if submitted is None else submitted.state.name)

    tally = tally_ballots(store, internal, context)
    spoiled = list(get_ballots(store, BallotBoxState.SPOILED).values())
    decryption = DecryptionMediator("decryption-mediator", context)
    decryption.announce(guardian.share_key(), guardian.compute_tally_share(tally, context),
                        guardian.compute_ballot_shares(spoiled, context))
    plaintext = decryption.get_plaintext_tally(tally, manifest)
    for c in plaintext.contests.values():
        print("tally", c.object_id, {s.object_id: s.tally for s in c.selections.values()})
    if spoil:
        decrypted = decryption.get_plaintext_ballots(spoiled, manifest)
        print("decrypted spoiled ballots:", sorted(decrypted))
    return plaintext

# Two ballot measures; each has selections with object_id "yes" and "no".
manifest = make_manifest([
    contest("measure-1", 0, [selection("yes", 0), selection("no", 1)]),
    contest("measure-2", 1, [selection("yes", 0), selection("no", 1)]),
])
run_election(manifest, [
    ballot("b1", {"measure-1": ["yes"], "measure-2": ["no"]}),
    ballot("b2", {"measure-1": ["yes"], "measure-2": ["no"]}),
    ballot("b3", {"measure-1": ["no"], "measure-2": ["no"]}),   # spoiled
], spoil={"b3"})
# Expected: manifest.is_valid() is False, since "yes" and "no" appear in both contests.
# Instead it is True, and measure-1 should be {yes: 2, no: 0} but is reported as {yes: 0, no: 2}.

Output (proof warnings omitted):

manifest.is_valid(): True
b1: cast -> CAST
b2: cast -> CAST
b3: spoil -> SPOILED
tally measure-1 {'yes': 0, 'no': 2}
tally measure-2 {'yes': 0, 'no': 2}
decrypted spoiled ballots: []

Environment

- OS: Ubuntu 24.04 (x86_64)
- Python: 3.12.3
- electionguard: 1.4.0, source at commit a975384
- gmpy2 2.3.0, pydantic 2.13.0, dacite 1.9.2 (the versions in `uv.lock`)

Anything else?

Control: using globally unique IDs (m1-yes, m1-no, m2-yes, m2-no) produces the correct totals and successfully decrypts b3.

Related: #168, #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriageWaiting to be triaged

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions