From f380607272aafaefcad5b7b754a1e1d40e4279f9 Mon Sep 17 00:00:00 2001 From: Alex Iribarren Date: Mon, 25 May 2026 19:20:14 +0200 Subject: [PATCH] Added voting chain v2 --- astra_app/core/admin.py | 64 + astra_app/core/election_chain.py | 294 +++ astra_app/core/elections_services.py | 84 +- astra_app/core/elections_timestamping.py | 77 +- astra_app/core/middleware.py | 1 + .../0097_election_manifest_chain_fields.py | 35 + ...e_receipt_template_manifest_digest_copy.py | 80 + ...99_alter_election_chain_version_default.py | 58 + astra_app/core/models.py | 269 ++- astra_app/core/static/verify-audit-log.py | 648 ++++++- astra_app/core/static/verify-ballot-chain.py | 756 +++++++- astra_app/core/templated_email.py | 9 +- .../core/templates/core/ballot_verify.html | 2 +- .../templates/core/election_algorithm.html | 19 +- .../tests/test_admin_election_lifecycle.py | 222 ++- .../tests/test_ballot_verification_page.py | 57 +- .../test_election_algorithm_docs_page.py | 59 +- .../core/tests/test_election_chain_v2.py | 432 +++++ .../tests/test_election_edit_lifecycle.py | 166 ++ ...on_vote_receipt_template_copy_migration.py | 28 +- astra_app/core/tests/test_elections_api.py | 6 + .../core/tests/test_elections_artifacts.py | 267 ++- .../tests/test_elections_audit_log_page.py | 136 +- .../core/tests/test_elections_edit_ui.py | 145 ++ astra_app/core/tests/test_elections_flow.py | 119 +- .../tests/test_elections_nomination_ssot.py | 112 +- .../test_elections_tiebreak_hardening.py | 145 +- .../core/tests/test_elections_timestamping.py | 5 +- .../test_templated_email_template_drift.py | 37 + .../core/tests/test_verification_scripts.py | 1702 ++++++++++++++++- ...est_verify_ballot_chain_script_guidance.py | 17 +- astra_app/core/tokens.py | 8 + astra_app/core/urls.py | 1 + astra_app/core/views_elections/__init__.py | 2 + astra_app/core/views_elections/audit.py | 50 +- .../core/views_elections/ballot_verify.py | 32 + astra_app/core/views_elections/detail.py | 47 +- astra_app/core/views_elections/edit.py | 319 +-- astra_app/core/views_elections/vote.py | 2 + docs/runbooks/meek-stv-elections.md | 16 +- .../src/ballot-verify/BallotVerifyPage.vue | 36 +- .../__tests__/ballotVerifyPage.test.ts | 70 +- frontend/src/ballot-verify/types.ts | 9 + .../ElectionAlgorithmPage.vue | 12 +- .../__tests__/electionAlgorithmPage.test.ts | 9 + .../ElectionAuditLogPage.vue | 39 +- .../__tests__/electionAuditLogPage.test.ts | 316 ++- .../src/election-vote/ElectionVotePage.vue | 117 +- .../__tests__/electionVotePage.test.ts | 35 + frontend/src/election-vote/types.ts | 2 + 50 files changed, 6782 insertions(+), 391 deletions(-) create mode 100644 astra_app/core/election_chain.py create mode 100644 astra_app/core/migrations/0097_election_manifest_chain_fields.py create mode 100644 astra_app/core/migrations/0098_update_election_vote_receipt_template_manifest_digest_copy.py create mode 100644 astra_app/core/migrations/0099_alter_election_chain_version_default.py create mode 100644 astra_app/core/tests/test_election_chain_v2.py diff --git a/astra_app/core/admin.py b/astra_app/core/admin.py index b111078e..a0ef7142 100644 --- a/astra_app/core/admin.py +++ b/astra_app/core/admin.py @@ -2365,6 +2365,24 @@ class CandidateInline(admin.TabularInline): fields = ("freeipa_username", "nominated_by", "url", "description") ordering = ("freeipa_username", "id") + @override + def get_readonly_fields(self, request: HttpRequest, obj: Election | None = None): + if isinstance(obj, Election) and obj.status != Election.Status.draft and int(obj.chain_version or 1) == 2: + return ("freeipa_username", "nominated_by") + return super().get_readonly_fields(request, obj) + + @override + def has_add_permission(self, request: HttpRequest, obj: Election | None = None) -> bool: + if isinstance(obj, Election) and obj.status != Election.Status.draft and int(obj.chain_version or 1) == 2: + return False + return super().has_add_permission(request, obj) + + @override + def has_delete_permission(self, request: HttpRequest, obj: Election | None = None) -> bool: + if isinstance(obj, Election) and obj.status != Election.Status.draft and int(obj.chain_version or 1) == 2: + return False + return super().has_delete_permission(request, obj) + @admin.register(Election) class ElectionAdmin(admin.ModelAdmin): @@ -2385,8 +2403,16 @@ class ElectionAdmin(admin.ModelAdmin): "close_elections_action", "tally_elections_action", ) + exclude = ("chain_version",) readonly_fields = ("status",) + @override + def get_readonly_fields(self, request: HttpRequest, obj: object | None = None): + readonly = list(super().get_readonly_fields(request, obj=obj)) + if isinstance(obj, Election) and obj.status != Election.Status.draft and int(obj.chain_version or 1) == 2: + readonly.extend(obj.v2_started_readonly_field_names()) + return tuple(dict.fromkeys(readonly)) + def issue_credentials_from_memberships_action(self, request: HttpRequest, queryset) -> None: for election in queryset: self.message_user( @@ -2438,6 +2464,13 @@ class CandidateAdmin(admin.ModelAdmin): search_fields = ("freeipa_username", "nominated_by", "election__name") ordering = ("election", "freeipa_username", "id") + @override + def get_readonly_fields(self, request: HttpRequest, obj: object | None = None): + readonly = list(super().get_readonly_fields(request, obj=obj)) + if isinstance(obj, Candidate) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + readonly.extend(["election", "freeipa_username", "nominated_by", "tiebreak_uuid"]) + return tuple(dict.fromkeys(readonly)) + @override def has_delete_permission(self, request: HttpRequest, obj: object | None = None) -> bool: if isinstance(obj, Candidate) and obj.election.status != Election.Status.draft: @@ -2456,6 +2489,24 @@ class ExclusionGroupCandidateInline(admin.TabularInline): extra = 0 autocomplete_fields = ("candidate",) + @override + def get_readonly_fields(self, request: HttpRequest, obj: ExclusionGroup | None = None): + if isinstance(obj, ExclusionGroup) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + return ("candidate",) + return super().get_readonly_fields(request, obj) + + @override + def has_add_permission(self, request: HttpRequest, obj: ExclusionGroup | None = None) -> bool: + if isinstance(obj, ExclusionGroup) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + return False + return super().has_add_permission(request, obj) + + @override + def has_delete_permission(self, request: HttpRequest, obj: ExclusionGroup | None = None) -> bool: + if isinstance(obj, ExclusionGroup) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + return False + return super().has_delete_permission(request, obj) + @admin.register(ExclusionGroup) class ExclusionGroupAdmin(admin.ModelAdmin): @@ -2465,6 +2516,19 @@ class ExclusionGroupAdmin(admin.ModelAdmin): ordering = ("election", "name", "id") inlines = (ExclusionGroupCandidateInline,) + @override + def get_readonly_fields(self, request: HttpRequest, obj: object | None = None): + readonly = list(super().get_readonly_fields(request, obj=obj)) + if isinstance(obj, ExclusionGroup) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + readonly.extend(["election", "name", "max_elected", "public_id", "candidates"]) + return tuple(dict.fromkeys(readonly)) + + @override + def has_delete_permission(self, request: HttpRequest, obj: object | None = None) -> bool: + if isinstance(obj, ExclusionGroup) and obj.election.status != Election.Status.draft and int(obj.election.chain_version or 1) == 2: + return False + return super().has_delete_permission(request, obj) + @admin.register(AuditLogEntry) class AuditLogEntryAdmin(ReadOnlyModelAdmin): diff --git a/astra_app/core/election_chain.py b/astra_app/core/election_chain.py new file mode 100644 index 00000000..59ba9cc9 --- /dev/null +++ b/astra_app/core/election_chain.py @@ -0,0 +1,294 @@ +import datetime +import json +from hashlib import sha256 + +from core.elections_meek import MEEK_DEFAULT_EPSILON, MEEK_DEFAULT_MAX_ITERATIONS +from core.models import Candidate, Election, ExclusionGroup, ExclusionGroupCandidate +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash + +CHAIN_VERSION_LEGACY = 1 +CHAIN_VERSION_CONFIG_ANCHOR_V2 = 2 +CONFIG_MANIFEST_VERSION = 1 +CHAIN_ROOT_KIND_LEGACY = "legacy_genesis" +CHAIN_ROOT_KIND_CONFIG_ANCHOR_V2 = "config_anchor_v2" +TALLY_RULE_ALGORITHM = "Meek STV (High-Precision Variant)" +TALLY_RULE_ALGORITHM_VERSION = "1.0" +TALLY_RULE_SPEC_IDENTITY = "docs/runbooks/meek-stv-elections.md" + + +def _canonical_datetime_utc(value: datetime.datetime) -> str: + normalized = value.astimezone(datetime.UTC).replace(microsecond=0) + return normalized.isoformat().replace("+00:00", "Z") + + +def load_locked_manifest_source_rows( + *, + election: Election, +) -> tuple[list[Candidate], list[ExclusionGroup], list[dict[str, int]]]: + candidate_rows = list( + Candidate.objects.select_for_update() + .filter(election=election) + .only("id", "freeipa_username", "nominated_by", "tiebreak_uuid") + .order_by("id") + ) + groups = list( + ExclusionGroup.objects.select_for_update() + .filter(election=election) + .only("id", "public_id", "name", "max_elected") + .order_by("public_id") + ) + group_candidates = list( + ExclusionGroupCandidate.objects.select_for_update() + .filter(exclusion_group__election=election) + .values("exclusion_group_id", "candidate_id") + .order_by("candidate_id") + ) + return candidate_rows, groups, group_candidates + + +def build_config_manifest( + *, + election: Election, + candidate_rows: list[Candidate] | None = None, + groups: list[ExclusionGroup] | None = None, + group_candidates: list[dict[str, int]] | None = None, +) -> dict[str, object]: + if candidate_rows is None: + candidate_rows = list( + Candidate.objects.filter(election=election) + .only("id", "freeipa_username", "nominated_by", "tiebreak_uuid") + .order_by("id") + ) + if groups is None: + groups = list( + ExclusionGroup.objects.filter(election=election) + .only("id", "public_id", "name", "max_elected") + .order_by("public_id") + ) + if group_candidates is None: + group_candidates = list( + ExclusionGroupCandidate.objects.filter(exclusion_group__election=election) + .values("exclusion_group_id", "candidate_id") + .order_by("candidate_id") + ) + + candidate_ids_by_group_id: dict[int, list[int]] = {} + for row in group_candidates: + group_id = int(row["exclusion_group_id"]) + candidate_ids_by_group_id.setdefault(group_id, []).append(int(row["candidate_id"])) + + return { + "version": CONFIG_MANIFEST_VERSION, + "election": { + "id": int(election.id), + "name": str(election.name), + "start_datetime": _canonical_datetime_utc(election.start_datetime), + "number_of_seats": int(election.number_of_seats), + "quorum": int(election.quorum), + "eligible_group_cn": str(election.eligible_group_cn or ""), + }, + "tally_rule": { + "algorithm": TALLY_RULE_ALGORITHM, + "algorithm_version": TALLY_RULE_ALGORITHM_VERSION, + "spec_identity": TALLY_RULE_SPEC_IDENTITY, + "epsilon": str(MEEK_DEFAULT_EPSILON), + "max_iterations": int(MEEK_DEFAULT_MAX_ITERATIONS), + }, + "candidates": [ + { + "id": int(candidate.id), + "freeipa_username": str(candidate.freeipa_username), + "nominated_by": str(candidate.nominated_by), + "tiebreak_uuid": str(candidate.tiebreak_uuid), + } + for candidate in candidate_rows + ], + "exclusion_groups": [ + { + "public_id": str(group.public_id), + "name": str(group.name), + "max_elected": int(group.max_elected), + "candidate_ids": sorted(candidate_ids_by_group_id.get(int(group.id), [])), + } + for group in groups + ], + } + + +def canonical_config_manifest_bytes(manifest: dict[str, object]) -> bytes: + return json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def config_manifest_sha256(manifest: dict[str, object]) -> str: + return sha256(canonical_config_manifest_bytes(manifest)).hexdigest() + + +def resolve_public_genesis_hash(*, payload: dict[str, object]) -> str: + resolved_hash = "" + for field_name in ("genesis_hash", "chain_anchor_hash", "genesis_chain_hash", "chain_root_hash"): + field_value = str(payload.get(field_name) or "").strip() + if not field_value: + continue + if resolved_hash and field_value != resolved_hash: + raise ValueError(f"{field_name} alias does not match genesis_hash") + resolved_hash = field_value + return resolved_hash + + +def stored_config_manifest(*, election: Election) -> dict[str, object]: + manifest = election.config_manifest + if not isinstance(manifest, dict): + raise ValueError("Election config_manifest must be a JSON object for v2 elections") + return manifest + + +def manifest_candidate_username_by_id_map(*, manifest: dict[str, object]) -> dict[int, str]: + candidates = manifest.get("candidates") + if not isinstance(candidates, list): + raise ValueError("config_manifest candidates must be a list") + + by_id: dict[int, str] = {} + for index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + raise ValueError(f"config_manifest candidates[{index}] must be an object") + candidate_id = candidate.get("id") + if not isinstance(candidate_id, int): + raise ValueError(f"config_manifest candidates[{index}].id must be an integer") + if candidate_id in by_id: + raise ValueError(f"config_manifest candidates[{index}].id is duplicated: {candidate_id}") + + for field_name in ("freeipa_username", "nominated_by", "tiebreak_uuid"): + field_value = candidate.get(field_name) + if not isinstance(field_value, str): + raise ValueError(f"config_manifest candidates[{index}].{field_name} must be a string") + + username = str(candidate["freeipa_username"]).strip() + if not username: + raise ValueError(f"config_manifest candidates[{index}].freeipa_username must not be blank") + + by_id[candidate_id] = str(candidate["freeipa_username"]) + return by_id + + +def validated_v2_manifest_state(*, election: Election) -> dict[str, object]: + if int(election.chain_version or CHAIN_VERSION_LEGACY) != CHAIN_VERSION_CONFIG_ANCHOR_V2: + raise ValueError("validated_v2_manifest_state requires chain_version=2") + + manifest_version = int(election.config_manifest_version or 0) + if manifest_version != CONFIG_MANIFEST_VERSION: + raise ValueError("config_manifest_version mismatch") + + manifest = stored_config_manifest(election=election) + if int(manifest.get("version") or 0) != CONFIG_MANIFEST_VERSION: + raise ValueError("config_manifest version mismatch") + + election_payload = manifest.get("election") + if not isinstance(election_payload, dict): + raise ValueError("config_manifest election must be an object") + + try: + manifest_election_id = int(election_payload.get("id")) + except (TypeError, ValueError): + raise ValueError("config_manifest election.id must be an integer") from None + if manifest_election_id != int(election.id): + raise ValueError("config_manifest election.id mismatch") + + for field_name in Election.v2_manifest_election_field_names(): + if field_name in {"id", "number_of_seats", "quorum"}: + continue + if not isinstance(election_payload.get(field_name), str): + raise ValueError(f"config_manifest election.{field_name} must be a string") + for field_name in ("number_of_seats", "quorum"): + if not isinstance(election_payload.get(field_name), int): + raise ValueError(f"config_manifest election.{field_name} must be an integer") + + tally_rule = manifest.get("tally_rule") + if not isinstance(tally_rule, dict): + raise ValueError("config_manifest tally_rule must be an object") + for field_name in ("algorithm", "algorithm_version", "spec_identity", "epsilon"): + if not isinstance(tally_rule.get(field_name), str): + raise ValueError(f"config_manifest tally_rule.{field_name} must be a string") + if not isinstance(tally_rule.get("max_iterations"), int): + raise ValueError("config_manifest tally_rule.max_iterations must be an integer") + + candidate_username_by_id = manifest_candidate_username_by_id_map(manifest=manifest) + + exclusion_groups = manifest.get("exclusion_groups") + if not isinstance(exclusion_groups, list): + raise ValueError("config_manifest exclusion_groups must be a list") + for index, group in enumerate(exclusion_groups): + if not isinstance(group, dict): + raise ValueError(f"config_manifest exclusion_groups[{index}] must be an object") + for field_name in ("public_id", "name"): + if not isinstance(group.get(field_name), str): + raise ValueError(f"config_manifest exclusion_groups[{index}].{field_name} must be a string") + if not isinstance(group.get("max_elected"), int): + raise ValueError(f"config_manifest exclusion_groups[{index}].max_elected must be an integer") + candidate_ids = group.get("candidate_ids") + if not isinstance(candidate_ids, list) or any(not isinstance(candidate_id, int) for candidate_id in candidate_ids): + raise ValueError(f"config_manifest exclusion_groups[{index}].candidate_ids must be a list of integers") + + computed_digest = config_manifest_sha256(manifest) + stored_digest = str(election.config_manifest_sha256 or "").strip().lower() + if computed_digest != stored_digest: + raise ValueError("config_manifest_sha256 mismatch") + + computed_anchor = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=computed_digest, + ) + stored_anchor = str(election.chain_anchor_hash or "").strip().lower() + if computed_anchor != stored_anchor: + raise ValueError("chain_anchor_hash mismatch") + + return { + "config_manifest_version": manifest_version, + "manifest": manifest, + "config_manifest_sha256": computed_digest, + "genesis_hash": computed_anchor, + "candidate_username_by_id": candidate_username_by_id, + } + + +def election_genesis_hash(*, election: Election) -> str: + if int(election.chain_version or CHAIN_VERSION_LEGACY) == CHAIN_VERSION_CONFIG_ANCHOR_V2: + genesis_hash = str(election.chain_anchor_hash or "").strip() + if not genesis_hash: + raise ValueError("v2 election is missing chain_anchor_hash") + return genesis_hash + return election_genesis_chain_hash(election.id) + + +def election_chain_root_kind(*, election: Election) -> str: + if int(election.chain_version or CHAIN_VERSION_LEGACY) == CHAIN_VERSION_CONFIG_ANCHOR_V2: + return CHAIN_ROOT_KIND_CONFIG_ANCHOR_V2 + return CHAIN_ROOT_KIND_LEGACY + + +def election_root_metadata( + *, + election: Election, + chain_head: str | None = None, + v2_manifest_state: dict[str, object] | None = None, + published_at: datetime.datetime | None = None, +) -> dict[str, object]: + genesis_hash = election_genesis_hash(election=election) + metadata: dict[str, object] = { + "election_id": int(election.id), + "chain_version": int(election.chain_version or CHAIN_VERSION_LEGACY), + "chain_root_kind": election_chain_root_kind(election=election), + "genesis_hash": genesis_hash, + "chain_head": str(chain_head or genesis_hash), + } + if int(election.chain_version or CHAIN_VERSION_LEGACY) == CHAIN_VERSION_CONFIG_ANCHOR_V2: + if v2_manifest_state is None: + metadata["config_manifest_version"] = int(election.config_manifest_version or CONFIG_MANIFEST_VERSION) + metadata["config_manifest_sha256"] = str(election.config_manifest_sha256 or "") + else: + metadata["config_manifest_version"] = int(v2_manifest_state["config_manifest_version"]) + metadata["config_manifest_sha256"] = str(v2_manifest_state["config_manifest_sha256"]) + if published_at is not None: + metadata["publication_bundle"] = { + "published_at": _canonical_datetime_utc(published_at), + } + return metadata \ No newline at end of file diff --git a/astra_app/core/elections_services.py b/astra_app/core/elections_services.py index 3477dac9..bcf0e950 100644 --- a/astra_app/core/elections_services.py +++ b/astra_app/core/elections_services.py @@ -18,6 +18,12 @@ from post_office.models import Email from core import signals as astra_signals +from core.election_chain import ( + CHAIN_VERSION_CONFIG_ANCHOR_V2, + election_genesis_hash, + election_root_metadata, + validated_v2_manifest_state, +) from core.elections_eligibility import start_eligible_voters from core.elections_timestamping import get_public_payload, schedule_attestation from core.email_context import ( @@ -36,7 +42,7 @@ ) from core.public_urls import build_public_absolute_url from core.templated_email import queue_composed_email, queue_templated_email -from core.tokens import election_chain_next_hash, election_genesis_chain_hash +from core.tokens import election_chain_next_hash ELECTION_TALLY_ALGORITHM_NAME = "Meek STV (High-Precision Variant)" ELECTION_TALLY_ALGORITHM_VERSION = "1.0" @@ -198,9 +204,18 @@ def candidate_username_by_id_map(candidates: Iterable[Candidate]) -> dict[int, s } -def build_public_ballots_export(*, election: Election) -> dict[str, object]: - candidates = Candidate.objects.filter(election=election).only("id", "freeipa_username") - candidate_name_by_id = candidate_username_by_id_map(candidates) +def build_public_ballots_export( + *, + election: Election, + published_at: datetime.datetime | None = None, +) -> dict[str, object]: + v2_manifest_state: dict[str, object] | None = None + if int(election.chain_version or 1) == CHAIN_VERSION_CONFIG_ANCHOR_V2: + v2_manifest_state = validated_v2_manifest_state(election=election) + candidate_name_by_id = dict(v2_manifest_state["candidate_username_by_id"]) + else: + candidates = Candidate.objects.filter(election=election).only("id", "freeipa_username") + candidate_name_by_id = candidate_username_by_id_map(candidates) ballots_qs = ( Ballot.objects.filter(election=election) @@ -227,6 +242,8 @@ def build_public_ballots_export(*, election: Election) -> dict[str, object]: except (TypeError, ValueError, OverflowError): continue name = candidate_name_by_id.get(candidate_id) + if int(election.chain_version or 1) == CHAIN_VERSION_CONFIG_ANCHOR_V2 and name is None: + raise ValueError(f"v2 manifest missing candidate id {candidate_id} used by ballot export") ranking_usernames.append(name if name else str(candidate_id)) ballots_payload.append( @@ -246,15 +263,31 @@ def build_public_ballots_export(*, election: Election) -> dict[str, object]: ) last_chain_hash = ballots_qs.values_list("chain_hash", flat=True).last() - chain_head = str(last_chain_hash or election_genesis_chain_hash(election.id)) + genesis_hash = election_genesis_hash(election=election) + chain_head = str(last_chain_hash or genesis_hash) + effective_published_at = published_at if published_at is not None else election.artifacts_generated_at - return { + payload = { + **election_root_metadata( + election=election, + chain_head=chain_head, + v2_manifest_state=v2_manifest_state, + published_at=effective_published_at, + ), "ballots": ballots_payload, - "chain_head": chain_head, } + return payload -def build_public_audit_export(*, election: Election) -> dict[str, object]: +def build_public_audit_export( + *, + election: Election, + published_at: datetime.datetime | None = None, +) -> dict[str, object]: + v2_manifest_state: dict[str, object] | None = None + if int(election.chain_version or 1) == CHAIN_VERSION_CONFIG_ANCHOR_V2: + v2_manifest_state = validated_v2_manifest_state(election=election) + entries = ( AuditLogEntry.objects.filter(election=election, is_public=True) .exclude(event_type="quorum_reached") @@ -281,6 +314,7 @@ def build_public_audit_export(*, election: Election) -> dict[str, object]: } if entry.rekor_log_id: + event["timestamp_utc"] = entry.timestamp.astimezone(datetime.UTC).isoformat().replace("+00:00", "Z") event["timestamping"] = { "version": 1, "rekor_log_id": entry.rekor_log_id, @@ -303,15 +337,27 @@ def build_public_audit_export(*, election: Election) -> dict[str, object]: if isinstance(algo, dict): algorithm = algo + genesis_hash = election_genesis_hash(election=election) + last_chain_hash = Ballot.objects.latest_chain_head_hash_for_election(election=election) + chain_head = str(last_chain_hash or genesis_hash) + effective_published_at = published_at if published_at is not None else election.artifacts_generated_at + return { + **election_root_metadata( + election=election, + chain_head=chain_head, + v2_manifest_state=v2_manifest_state, + published_at=effective_published_at, + ), "algorithm": algorithm, "audit_log": audit_log, } def persist_public_election_artifacts(*, election: Election) -> None: - ballots_payload = build_public_ballots_export(election=election) - audit_payload = build_public_audit_export(election=election) + published_at = timezone.now() + ballots_payload = build_public_ballots_export(election=election, published_at=published_at) + audit_payload = build_public_audit_export(election=election, published_at=published_at) ballots_content = ContentFile( json.dumps(ballots_payload, cls=DjangoJSONEncoder, sort_keys=True).encode("utf-8") @@ -322,7 +368,7 @@ def persist_public_election_artifacts(*, election: Election) -> None: election.public_ballots_file.save("public-ballots.json", ballots_content, save=False) election.public_audit_file.save("public-audit.json", audit_content, save=False) - election.artifacts_generated_at = timezone.now() + election.artifacts_generated_at = published_at election.save(update_fields=["public_ballots_file", "public_audit_file", "artifacts_generated_at"]) @@ -418,6 +464,8 @@ def send_vote_receipt_email( **election_committee_email_context(), **_election_email_context(election=election, tz_name=tz_name), "ballot_hash": receipt.ballot.ballot_hash, + "chain_version": int(election.chain_version or 1), + "config_manifest_sha256": str(election.config_manifest_sha256 or ""), "nonce": receipt.nonce, "weight": receipt.ballot.weight, "previous_chain_hash": receipt.ballot.previous_chain_hash, @@ -616,8 +664,7 @@ def submit_ballot(*, election: Election, credential_public_id: str, ranking: lis ) last_chain_hash = Ballot.objects.latest_chain_head_hash_for_election(election=election) - genesis_hash = election_genesis_chain_hash(election.id) - previous_chain_hash = str(last_chain_hash or genesis_hash) + previous_chain_hash = str(last_chain_hash or election_genesis_hash(election=election)) chain_hash = election_chain_next_hash(previous_chain_hash=previous_chain_hash, ballot_hash=ballot_hash) current = ( @@ -944,8 +991,7 @@ def close_election(*, election: Election, actor: str | None = None) -> None: ended_at = timezone.now() last_chain_hash = Ballot.objects.latest_chain_head_hash_for_election(election=election) - genesis_hash = election_genesis_chain_hash(election.id) - chain_head = str(last_chain_hash or genesis_hash) + chain_head = str(last_chain_hash or election_genesis_hash(election=election)) election.status = Election.Status.closed election.end_datetime = ended_at @@ -1082,8 +1128,6 @@ def tally_election(*, election: Election, actor: str | None = None) -> dict[str, election.status = Election.Status.tallied election.save(update_fields=["tally_result", "status"]) - persist_public_election_artifacts(election=election) - for idx, round_payload in enumerate(result.get("rounds") or [], start=1): AuditLogEntry.objects.create( election=election, @@ -1112,9 +1156,15 @@ def tally_election(*, election: Election, actor: str | None = None) -> dict[str, is_public=True, ) schedule_attestation(tally_completed_entry) + persist_public_election_artifacts(election=election) tallied_election_id = election.id + def _persist_tallied_public_artifacts() -> None: + persist_public_election_artifacts(election=Election.objects.get(pk=tallied_election_id)) + + transaction.on_commit(_persist_tallied_public_artifacts) + def _send_tallied_signal() -> None: committed_election = Election.objects.get(pk=tallied_election_id) astra_signals.election_tallied.send( diff --git a/astra_app/core/elections_timestamping.py b/astra_app/core/elections_timestamping.py index cc59d624..8c99bc45 100644 --- a/astra_app/core/elections_timestamping.py +++ b/astra_app/core/elections_timestamping.py @@ -31,6 +31,10 @@ CANONICAL_MESSAGE_VERSION = 1 +class ElectionAttestationError(Exception): + pass + + def get_public_payload(entry: AuditLogEntry) -> Any: if entry.event_type == "rekor_attestation_failed": return {} @@ -39,19 +43,30 @@ def get_public_payload(entry: AuditLogEntry) -> Any: if isinstance(payload, dict): if entry.event_type == "election_started": public_payload: dict[str, object] = {} - if "genesis_chain_hash" in payload: - public_payload["genesis_chain_hash"] = payload["genesis_chain_hash"] - candidates = payload.get("candidates") - if isinstance(candidates, list): - public_payload["candidates"] = [ - { - "id": candidate.get("id"), - "freeipa_username": candidate.get("freeipa_username"), - "tiebreak_uuid": candidate.get("tiebreak_uuid"), - } - for candidate in candidates - if isinstance(candidate, dict) - ] + if int(payload.get("chain_version") or 1) == 2: + for key in ( + "chain_version", + "config_manifest_version", + "config_manifest_sha256", + "chain_anchor_hash", + "config_manifest", + ): + if key in payload: + public_payload[key] = payload[key] + else: + if "genesis_chain_hash" in payload: + public_payload["genesis_chain_hash"] = payload["genesis_chain_hash"] + candidates = payload.get("candidates") + if isinstance(candidates, list): + public_payload["candidates"] = [ + { + "id": candidate.get("id"), + "freeipa_username": candidate.get("freeipa_username"), + "tiebreak_uuid": candidate.get("tiebreak_uuid"), + } + for candidate in candidates + if isinstance(candidate, dict) + ] return public_payload public_payload = dict(payload) @@ -92,6 +107,21 @@ def _load_private_key() -> ec.EllipticCurvePrivateKey: return loaded_key +def rekor_signing_public_key_material( + *, private_key: ec.EllipticCurvePrivateKey +) -> tuple[bytes, str]: + public_key = private_key.public_key() + public_key_pem_bytes = public_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_key_der_bytes = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return public_key_pem_bytes, hashlib.sha256(public_key_der_bytes).hexdigest() + + def _extract_conflict_uuid(response: requests.Response) -> str: location = str(response.headers.get("Location") or "").strip() if location: @@ -125,10 +155,7 @@ def _attest_entry(entry: AuditLogEntry) -> None: private_key = _load_private_key() signature_der_bytes = private_key.sign(canonical_bytes, ec.ECDSA(hashes.SHA256())) # Rekor hashedrekord requires base64(PEM), not base64(DER), for the public key. - pubkey_pem_bytes = private_key.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - ) + pubkey_pem_bytes, _public_key_fingerprint = rekor_signing_public_key_material(private_key=private_key) payload: dict[str, object] = { "apiVersion": "0.0.1", @@ -264,3 +291,19 @@ def _on_commit() -> None: entry.id, extra=current_exception_log_fields(), ) + + +def attest_entry_or_raise(entry: AuditLogEntry) -> None: + endpoint = str(settings.ELECTION_REKOR_ENDPOINT or "").strip() + signing_key = str(settings.ELECTION_REKOR_SIGNING_KEY or "").strip() + if not endpoint: + raise ElectionAttestationError("Election start did not complete: Rekor endpoint is not configured.") + if not signing_key: + raise ElectionAttestationError("Election start did not complete: Rekor signing key is not configured.") + if entry.event_type not in ATTESTED_EVENT_TYPES: + raise ElectionAttestationError(f"Election start did not complete: unsupported attested event {entry.event_type}.") + + try: + _attest_entry(entry) + except Exception as exc: + raise ElectionAttestationError("Election start did not complete because attestation failed.") from exc diff --git a/astra_app/core/middleware.py b/astra_app/core/middleware.py index 3ab7d2fa..eb709571 100644 --- a/astra_app/core/middleware.py +++ b/astra_app/core/middleware.py @@ -474,6 +474,7 @@ def __init__(self, get_response): "/logout", "/otp/sync", "/password-expired", + "/elections/verify-audit-log.py", "/api/v1/password-reset/detail", "/api/v1/password-reset/confirm/detail", "/api/v1/password-expired/detail", diff --git a/astra_app/core/migrations/0097_election_manifest_chain_fields.py b/astra_app/core/migrations/0097_election_manifest_chain_fields.py new file mode 100644 index 00000000..bc374ccb --- /dev/null +++ b/astra_app/core/migrations/0097_election_manifest_chain_fields.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0096_update_election_vote_receipt_template_ballot_verification_copy"), + ] + + operations = [ + migrations.AddField( + model_name="election", + name="chain_version", + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AddField( + model_name="election", + name="config_manifest_version", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="election", + name="config_manifest", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="election", + name="config_manifest_sha256", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="election", + name="chain_anchor_hash", + field=models.CharField(blank=True, default="", max_length=64), + ), + ] \ No newline at end of file diff --git a/astra_app/core/migrations/0098_update_election_vote_receipt_template_manifest_digest_copy.py b/astra_app/core/migrations/0098_update_election_vote_receipt_template_manifest_digest_copy.py new file mode 100644 index 00000000..2016c09e --- /dev/null +++ b/astra_app/core/migrations/0098_update_election_vote_receipt_template_manifest_digest_copy.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Any + +from django.db import migrations + +from core.migration_helpers.email_template_text import text_from_html + + +def update_election_vote_receipt_template_manifest_digest_copy(apps: Any, schema_editor: Any) -> None: + del schema_editor + EmailTemplate = apps.get_model("post_office", "EmailTemplate") + + html_content = ( + "

Hello {{ username }},

\n" + "

Your vote for {{ election_name }} has been successfully recorded.

\n" + "

The election closes: {{ election_end_datetime }}

\n" + "
\n" + "

Your ballot receipt

\n" + "

Ballot receipt code:
{{ ballot_hash }}

\n" + "

Submission nonce:
{{ nonce }}

\n" + "

Vote weight:
{{ weight }}

\n" + "

Please save the ballot receipt code and the submission nonce if you want to verify your vote later. Together, they let you confirm that the system recorded your ballot correctly.

\n" + "
\n" + "

Verify your ballot

\n" + "

You can verify that your ballot was recorded and included in the election ledger here:

\n" + "

{{ verify_url }}

\n" + "

This verification confirms that a ballot with your ballot receipt code is recorded in the system. It does not display your vote choices.

\n" + "
\n" + "

Ballot integrity ledger (advanced)

\n" + "

To make ballot storage tamper-evident, ballots are recorded in an append-only cryptographic ledger.

\n" + "\n" + "

These values allow independent auditors to verify that ballots were not altered or removed after submission.

\n" + "
\n" + "

Election definition checkpoint

\n" + "

Election definition digest:
{{ config_manifest_sha256 }}

\n" + "

This digest is the frozen election definition at start. Compare this digest with the public audit log and the ballot verification page if you need to confirm the election definition that your ballot was recorded under.

\n" + "
\n" + "

Important information

\n" + "\n" + "

Privacy note

\n" + "\n" + "

Thank you for participating in the election!

\n" + "

The AlmaLinux Team

" + ) + + EmailTemplate.objects.update_or_create( + name="election-vote-receipt", + defaults={ + "subject": "Vote receipt for {{ election_name }}", + "content": text_from_html(html_content), + "html_content": html_content, + }, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0097_election_manifest_chain_fields"), + ("post_office", "0013_email_recipient_delivery_status_alter_log_status"), + ] + + operations = [ + migrations.RunPython( + update_election_vote_receipt_template_manifest_digest_copy, + migrations.RunPython.noop, + ), + ] diff --git a/astra_app/core/migrations/0099_alter_election_chain_version_default.py b/astra_app/core/migrations/0099_alter_election_chain_version_default.py new file mode 100644 index 00000000..89268889 --- /dev/null +++ b/astra_app/core/migrations/0099_alter_election_chain_version_default.py @@ -0,0 +1,58 @@ +from django.db import migrations, models + + +CHAIN_VERSION_IMMUTABLE_SQL = """ +CREATE OR REPLACE FUNCTION core_election_restrict_chain_version_update() RETURNS trigger AS $$ +BEGIN + IF NEW.chain_version IS DISTINCT FROM OLD.chain_version THEN + RAISE EXCEPTION 'election chain_version cannot change after insert'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS core_election_restrict_chain_version_update_trg ON core_election; +CREATE TRIGGER core_election_restrict_chain_version_update_trg +BEFORE UPDATE ON core_election +FOR EACH ROW +EXECUTE FUNCTION core_election_restrict_chain_version_update(); +""" + + +CHAIN_VERSION_IMMUTABLE_SQL_REVERSE = """ +DROP TRIGGER IF EXISTS core_election_restrict_chain_version_update_trg ON core_election; +DROP FUNCTION IF EXISTS core_election_restrict_chain_version_update(); +""" + + +def _create_chain_version_trigger(apps, schema_editor) -> None: + if schema_editor.connection.vendor != "postgresql": + return + + schema_editor.execute(CHAIN_VERSION_IMMUTABLE_SQL) + + +def _drop_chain_version_trigger(apps, schema_editor) -> None: + if schema_editor.connection.vendor != "postgresql": + return + + schema_editor.execute(CHAIN_VERSION_IMMUTABLE_SQL_REVERSE) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0098_update_election_vote_receipt_template_manifest_digest_copy"), + ] + + operations = [ + migrations.AlterField( + model_name="election", + name="chain_version", + field=models.PositiveSmallIntegerField(default=2), + ), + migrations.RunPython( + code=_create_chain_version_trigger, + reverse_code=_drop_chain_version_trigger, + ), + ] \ No newline at end of file diff --git a/astra_app/core/models.py b/astra_app/core/models.py index 240a1bd3..2149cd39 100644 --- a/astra_app/core/models.py +++ b/astra_app/core/models.py @@ -7,7 +7,7 @@ import uuid import warnings from io import BytesIO -from typing import override +from typing import ClassVar, override from django.conf import settings from django.core.exceptions import ValidationError @@ -1357,6 +1357,24 @@ def active(self) -> ElectionQuerySet: class Election(models.Model): + V2_IMMUTABLE_FIELD_NAMES: ClassVar[tuple[str, ...]] = ( + "name", + "start_datetime", + "number_of_seats", + "quorum", + "eligible_group_cn", + ) + V2_MANIFEST_BACKED_FIELD_NAMES: ClassVar[tuple[str, ...]] = ( + "config_manifest_version", + "config_manifest", + "config_manifest_sha256", + "chain_anchor_hash", + ) + V2_MANIFEST_ELECTION_FIELD_NAMES: ClassVar[tuple[str, ...]] = ( + "id", + *V2_IMMUTABLE_FIELD_NAMES, + ) + class Status(models.TextChoices): draft = "draft", "Draft" open = "open", "Open" @@ -1384,6 +1402,11 @@ class Status(models.TextChoices): ), ) status = models.CharField(max_length=16, choices=Status.choices, default=Status.draft) + chain_version = models.PositiveSmallIntegerField(default=2) + config_manifest_version = models.PositiveSmallIntegerField(blank=True, null=True) + config_manifest = models.JSONField(blank=True, default=dict) + config_manifest_sha256 = models.CharField(max_length=64, blank=True, default="") + chain_anchor_hash = models.CharField(max_length=64, blank=True, default="") # Published machine-readable tally output. tally_result = models.JSONField(blank=True, default=dict) @@ -1422,9 +1445,116 @@ class Status(models.TextChoices): class Meta: ordering = ("-start_datetime", "id") + @classmethod + def v2_manifest_election_field_names(cls) -> tuple[str, ...]: + return cls.V2_MANIFEST_ELECTION_FIELD_NAMES + + @classmethod + def v2_immutable_field_names(cls) -> tuple[str, ...]: + return cls.V2_IMMUTABLE_FIELD_NAMES + + @classmethod + def v2_started_readonly_field_names(cls) -> tuple[str, ...]: + return ( + *cls.V2_IMMUTABLE_FIELD_NAMES, + "chain_version", + *cls.V2_MANIFEST_BACKED_FIELD_NAMES, + ) + + def v2_immutable_field_values(self) -> dict[str, object]: + field_values = { + "name": self.name, + "start_datetime": self.start_datetime, + "number_of_seats": self.number_of_seats, + "quorum": self.quorum, + "eligible_group_cn": self.eligible_group_cn, + } + return { + field_name: field_values[field_name] + for field_name in self.v2_immutable_field_names() + } + def __str__(self) -> str: return self.name + def _validate_chain_contract(self) -> None: + if self.pk is None: + return + + original = Election.objects.only( + "status", + *self.v2_started_readonly_field_names(), + ).get(pk=self.pk) + + if self.chain_version != original.chain_version: + raise ValidationError({"chain_version": "Cannot change chain_version after creation."}) + + if original.status == Election.Status.draft: + return + + manifest_field_values = { + "config_manifest_version": self.config_manifest_version, + "config_manifest": self.config_manifest, + "config_manifest_sha256": self.config_manifest_sha256, + "chain_anchor_hash": self.chain_anchor_hash, + } + manifest_fields = { + field_name: manifest_field_values[field_name] + for field_name in self.V2_MANIFEST_BACKED_FIELD_NAMES + } + original_manifest_field_values = { + "config_manifest_version": original.config_manifest_version, + "config_manifest": original.config_manifest, + "config_manifest_sha256": original.config_manifest_sha256, + "chain_anchor_hash": original.chain_anchor_hash, + } + original_manifest_fields = { + field_name: original_manifest_field_values[field_name] + for field_name in self.V2_MANIFEST_BACKED_FIELD_NAMES + } + + if int(original.chain_version or 1) != 2: + for field_name, value in manifest_fields.items(): + if value != original_manifest_fields[field_name]: + raise ValidationError( + {field_name: f"Cannot change {field_name} on a {original.status} legacy election."} + ) + return + + immutable_fields = self.v2_immutable_field_values() + immutable_fields.update(manifest_fields) + original_immutable_fields = original.v2_immutable_field_values() + original_immutable_fields.update(original_manifest_fields) + + for field_name, value in immutable_fields.items(): + if value != original_immutable_fields[field_name]: + raise ValidationError({field_name: f"Cannot change {field_name} on a {original.status} v2 election."}) + + @override + def clean(self) -> None: + super().clean() + self._validate_chain_contract() + + @override + def save(self, *args, **kwargs) -> None: + self._validate_chain_contract() + super().save(*args, **kwargs) + + +def _election_chain_state(*, election_id: int, lock: bool = False) -> tuple[str, int]: + queryset = Election.objects + if lock: + queryset = queryset.select_for_update() + election_status, chain_version = queryset.values_list("status", "chain_version").get(pk=election_id) + return str(election_status), int(chain_version or 1) + + +def _started_v2_election_status(*, election_id: int, lock: bool = False) -> str | None: + election_status, chain_version = _election_chain_state(election_id=election_id, lock=lock) + if election_status == Election.Status.draft or int(chain_version or 1) != 2: + return None + return str(election_status) + class Candidate(models.Model): election = models.ForeignKey(Election, on_delete=models.CASCADE, related_name="candidates") @@ -1453,25 +1583,61 @@ class Meta: def __str__(self) -> str: return f"{self.freeipa_username} ({self.election_id})" + def _validate_candidate_immutability(self, *, lock_election: bool = False) -> None: + if self.pk is None and self.election_id is None: + return + + election_status = _started_v2_election_status(election_id=self.election_id, lock=lock_election) + if self.pk is None: + if election_status is not None: + raise ValidationError(f"Cannot add a candidate to a {election_status} v2 election.") + return + + if election_status is None: + election_status, _chain_version = _election_chain_state(election_id=self.election_id, lock=lock_election) + if election_status == Election.Status.draft: + return + original = Candidate.objects.only("tiebreak_uuid").get(pk=self.pk) + if self.tiebreak_uuid != original.tiebreak_uuid: + raise ValidationError( + f"Cannot change tiebreak_uuid on a candidate in a {election_status} election." + ) + return + + original = Candidate.objects.only( + "freeipa_username", + "nominated_by", + "tiebreak_uuid", + ).get(pk=self.pk) + immutable_fields = ( + ("freeipa_username", self.freeipa_username, original.freeipa_username), + ("nominated_by", self.nominated_by, original.nominated_by), + ("tiebreak_uuid", self.tiebreak_uuid, original.tiebreak_uuid), + ) + for field_name, new_value, old_value in immutable_fields: + if new_value != old_value: + raise ValidationError( + {field_name: f"Cannot change {field_name} on a candidate in a {election_status} v2 election."} + ) + + @override + def clean(self) -> None: + super().clean() + self._validate_candidate_immutability() + @override def save(self, *args, **kwargs) -> None: - # Enforce tiebreak_uuid immutability once the election is started. - if self.pk is not None: - election_status = Election.objects.values_list("status", flat=True).get(pk=self.election_id) - if election_status != Election.Status.draft: - original_uuid = Candidate.objects.values_list("tiebreak_uuid", flat=True).get(pk=self.pk) - if self.tiebreak_uuid != original_uuid: - raise ValidationError( - f"Cannot change tiebreak_uuid on a candidate in a {election_status} election." - ) - super().save(*args, **kwargs) + with transaction.atomic(): + self._validate_candidate_immutability(lock_election=True) + super().save(*args, **kwargs) @override def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: - election_status = Election.objects.values_list("status", flat=True).get(pk=self.election_id) - if election_status != Election.Status.draft: - raise ValidationError(f"Cannot delete a candidate from a {election_status} election.") - return super().delete(*args, **kwargs) + with transaction.atomic(): + election_status, _chain_version = _election_chain_state(election_id=self.election_id, lock=True) + if election_status != Election.Status.draft: + raise ValidationError(f"Cannot delete a candidate from a {election_status} election.") + return super().delete(*args, **kwargs) class ExclusionGroup(models.Model): @@ -1500,6 +1666,48 @@ class Meta: def __str__(self) -> str: return f"{self.election_id}:{self.name}" + def _validate_group_immutability(self, *, lock_election: bool = False) -> None: + if self.pk is None and self.election_id is None: + return + + election_status = _started_v2_election_status(election_id=self.election_id, lock=lock_election) + if self.pk is None: + if election_status is not None: + raise ValidationError(f"Cannot add an exclusion group to a {election_status} v2 election.") + return + if election_status is None: + return + + original = ExclusionGroup.objects.only("name", "max_elected").get(pk=self.pk) + immutable_fields = ( + ("name", self.name, original.name), + ("max_elected", self.max_elected, original.max_elected), + ) + for field_name, new_value, old_value in immutable_fields: + if new_value != old_value: + raise ValidationError( + {field_name: f"Cannot change {field_name} on an exclusion group in a {election_status} v2 election."} + ) + + @override + def clean(self) -> None: + super().clean() + self._validate_group_immutability() + + @override + def save(self, *args, **kwargs) -> None: + with transaction.atomic(): + self._validate_group_immutability(lock_election=True) + super().save(*args, **kwargs) + + @override + def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: + with transaction.atomic(): + election_status = _started_v2_election_status(election_id=self.election_id, lock=True) + if election_status is not None: + raise ValidationError(f"Cannot delete an exclusion group from a {election_status} v2 election.") + return super().delete(*args, **kwargs) + class ExclusionGroupCandidate(models.Model): exclusion_group = models.ForeignKey(ExclusionGroup, on_delete=models.CASCADE, related_name="group_candidates") @@ -1516,6 +1724,33 @@ class Meta: def __str__(self) -> str: return f"{self.exclusion_group_id}:{self.candidate_id}" + def _validate_membership_immutability(self, *, lock_election: bool = False) -> None: + election_status = _started_v2_election_status( + election_id=self.exclusion_group.election_id, + lock=lock_election, + ) + if election_status is not None: + raise ValidationError( + f"Cannot change exclusion group membership in a {election_status} v2 election." + ) + + @override + def clean(self) -> None: + super().clean() + self._validate_membership_immutability() + + @override + def save(self, *args, **kwargs) -> None: + with transaction.atomic(): + self._validate_membership_immutability(lock_election=True) + super().save(*args, **kwargs) + + @override + def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: + with transaction.atomic(): + self._validate_membership_immutability(lock_election=True) + return super().delete(*args, **kwargs) + class VotingCredential(models.Model): election = models.ForeignKey(Election, on_delete=models.CASCADE, related_name="credentials") @@ -1885,7 +2120,7 @@ def _invalidate_badge_cache_on_membership_request_change( ) -> None: """Invalidate badge count cache when membership requests change.""" from core.membership import invalidate_membership_review_badge_cache - + invalidate_membership_review_badge_cache() @@ -1897,5 +2132,5 @@ def _invalidate_badge_cache_on_membership_request_delete( ) -> None: """Invalidate badge count cache when membership requests are deleted.""" from core.membership import invalidate_membership_review_badge_cache - + invalidate_membership_review_badge_cache() diff --git a/astra_app/core/static/verify-audit-log.py b/astra_app/core/static/verify-audit-log.py index 2d7a4a3c..ac8cb1b5 100644 --- a/astra_app/core/static/verify-audit-log.py +++ b/astra_app/core/static/verify-audit-log.py @@ -1,31 +1,54 @@ #!/usr/bin/env python3 """ -Verify Rekor transparency-log attestations in public-audit.json (local + optional online check) +Verify Rekor transparency-log attestations in public-audit.json (local + optional online trust check) This script verifies that events in public-audit.json that carry a Rekor attestation have correct canonical message digests and (optionally) that the logged Rekor entries exist and match. +This script validates the audit and attestation record only. It does not prove that +public-ballots.json is the matching ballot-ledger publication pair. + Two verification modes: - Offline (always): recompute the canonical SHA-256 digest from the exported event - data and compare it to timestamping.message_digest_hex. - Online (optional): fetch each rekor_entry_url to confirm the entry exists in the - Rekor log and that the logged hash matches. + Offline (always): recompute the canonical SHA-256 digest from the exported event + data and compare it to timestamping.message_digest_hex. + Online (optional): fetch each rekor_entry_url, confirm the entry exists in the + Rekor log, verify the hashedrekord signature over the canonical + bytes, and require the embedded attestation signer to match the + trusted public key or fingerprint configured below. + +The hardcoded trusted key material below is the default trust root for the attestation +signer embedded in the Rekor entry payload. It is not Rekor's own transparency-log key. -This script runs with Python stdlib only (no pip install required). +Online attestation-signer verification uses the local OpenSSL executable. """ # ===== USER INPUT ===== # Download public-audit.json from the election page and keep it next to this script. audit_file: str = "public-audit.json" -# Set True to also query each Rekor entry URL via HTTPS. -verify_rekor_online: bool = False +# Set False to not query each Rekor entry URL via HTTPS. +verify_rekor_online: bool = True +# Trusted attestation signer for the environment whose Rekor entry is being +# audited. This is the signer embedded in the attestation payload, not Rekor's +# transparency-log key. Replace these only when auditing a different signer. +trusted_public_key_pem: str = """-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6VF2PVPmOMIg5eRV1+MIK/hRXy53 +4wHhKn77HEEZP5mfbkOtEkVEVsO8W4X0dsubDOlAcx49ckaAH/KGMsHPkQ== +-----END PUBLIC KEY-----""" +trusted_public_key_sha256: str = "99f3b7b90a6d81ac36e8aaf8066d3da0d7ccd49dab06995ad6eeb83384a0dd12" +# Require Rekor integrated time to be within this many seconds of the original +# exported audit-entry timestamp before v2 verification can reach `valid`. +rekor_integrated_time_tolerance_seconds: int = 5 # ===== END OF USER INPUT ===== +CONFIG_MANIFEST_VERSION = 1 + import base64 # noqa: E402 import datetime # noqa: E402 import hashlib # noqa: E402 import json # noqa: E402 +import subprocess # noqa: E402 +import tempfile # noqa: E402 from urllib import request as _urllib_request # noqa: E402 @@ -38,6 +61,335 @@ def _canonical_bytes(*, event_type: object, payload: object) -> bytes: ).encode("utf-8") +def _canonical_manifest_bytes(manifest: dict[str, object]) -> bytes: + return json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _compute_chain_anchor_hash(*, election_id: int, config_manifest_sha256: str) -> str: + data = ( + f"election-v2:{election_id}:{config_manifest_sha256}. " + "alex estuvo aquí, dejándose el alma." + ).encode() + return hashlib.sha256(data).hexdigest() + + +def _normalized_sha256_hex(value: object) -> str: + return str(value or "").strip().lower() + + +def _resolve_payload_genesis_hash(*, payload: dict[str, object], label: str) -> str: + canonical_genesis = _normalized_sha256_hex(payload.get("genesis_hash")) + legacy_anchor = _normalized_sha256_hex(payload.get("chain_anchor_hash")) + legacy_genesis = _normalized_sha256_hex(payload.get("genesis_chain_hash")) + legacy_root = _normalized_sha256_hex(payload.get("chain_root_hash")) + if canonical_genesis and legacy_anchor and canonical_genesis != legacy_anchor: + raise ValueError(f"{label} chain_anchor_hash alias does not match genesis_hash") + if canonical_genesis and legacy_genesis and canonical_genesis != legacy_genesis: + raise ValueError(f"{label} genesis_chain_hash alias does not match genesis_hash") + if canonical_genesis and legacy_root and canonical_genesis != legacy_root: + raise ValueError(f"{label} chain_root_hash alias does not match genesis_hash") + resolved = canonical_genesis or legacy_anchor or legacy_genesis or legacy_root + if not resolved: + raise ValueError(f"{label} missing genesis_hash") + return resolved + + +def _require_supported_manifest_version(*, value: object, label: str) -> int: + if value in (None, ""): + raise ValueError(f"{label} missing config_manifest_version") + try: + manifest_version = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label} config_manifest_version must be an integer") from exc + if manifest_version != CONFIG_MANIFEST_VERSION: + raise ValueError( + f"{label} config_manifest_version must be {CONFIG_MANIFEST_VERSION} for v2 verification" + ) + return manifest_version + + +def _require_manifest_dict_field(*, data: dict[str, object], field_name: str, label: str) -> dict[str, object]: + value = data.get(field_name) + if not isinstance(value, dict): + raise ValueError(f"{label} missing {field_name} section") + return value + + +def _require_manifest_list_field(*, data: dict[str, object], field_name: str, label: str) -> list[object]: + value = data.get(field_name) + if not isinstance(value, list): + raise ValueError(f"{label} {field_name} must be a list") + return value + + +def _require_manifest_int(*, data: dict[str, object], field_name: str, label: str) -> int: + value = data.get(field_name) + if isinstance(value, bool): + raise ValueError(f"{label} {field_name} must be an integer") + if not isinstance(value, int): + raise ValueError(f"{label} {field_name} must be an integer") + return value + + +def _require_manifest_str(*, data: dict[str, object], field_name: str, label: str) -> str: + value = data.get(field_name) + if not isinstance(value, str): + raise ValueError(f"{label} {field_name} must be a string") + return value + + +def _validate_manifest_v1_schema(*, manifest: dict[str, object]) -> None: + election = _require_manifest_dict_field(data=manifest, field_name="election", label="config_manifest") + tally_rule = _require_manifest_dict_field(data=manifest, field_name="tally_rule", label="config_manifest") + candidates = _require_manifest_list_field(data=manifest, field_name="candidates", label="config_manifest") + exclusion_groups = _require_manifest_list_field( + data=manifest, + field_name="exclusion_groups", + label="config_manifest", + ) + + _require_manifest_int(data=election, field_name="id", label="config_manifest election") + for field_name in ( + "name", + "start_datetime", + "eligible_group_cn", + ): + _require_manifest_str(data=election, field_name=field_name, label="config_manifest election") + for field_name in ("number_of_seats", "quorum"): + _require_manifest_int(data=election, field_name=field_name, label="config_manifest election") + + for field_name in ("algorithm", "algorithm_version", "spec_identity", "epsilon"): + _require_manifest_str(data=tally_rule, field_name=field_name, label="config_manifest tally_rule") + _require_manifest_int(data=tally_rule, field_name="max_iterations", label="config_manifest tally_rule") + + for index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + raise ValueError(f"config_manifest candidates[{index}] must be an object") + _require_manifest_int(data=candidate, field_name="id", label=f"config_manifest candidates[{index}]") + for field_name in ( + "freeipa_username", + "nominated_by", + "tiebreak_uuid", + ): + _require_manifest_str( + data=candidate, + field_name=field_name, + label=f"config_manifest candidates[{index}]", + ) + + for index, group in enumerate(exclusion_groups): + if not isinstance(group, dict): + raise ValueError(f"config_manifest exclusion_groups[{index}] must be an object") + for field_name in ("public_id", "name"): + _require_manifest_str( + data=group, + field_name=field_name, + label=f"config_manifest exclusion_groups[{index}]", + ) + _require_manifest_int( + data=group, + field_name="max_elected", + label=f"config_manifest exclusion_groups[{index}]", + ) + candidate_ids = _require_manifest_list_field( + data=group, + field_name="candidate_ids", + label=f"config_manifest exclusion_groups[{index}]", + ) + for candidate_index, candidate_id in enumerate(candidate_ids): + if isinstance(candidate_id, bool) or not isinstance(candidate_id, int): + raise ValueError( + "config_manifest exclusion_groups" + f"[{index}] candidate_ids[{candidate_index}] must be an integer" + ) + + +def _format_utc(dt: datetime.datetime) -> str: + return dt.astimezone(datetime.UTC).isoformat().replace("+00:00", "Z") + + +def _parse_precise_utc_timestamp(value: object) -> datetime.datetime | None: + text = str(value or "").strip() + if not text or "T" not in text: + return None + normalized = text[:-1] + "+00:00" if text.endswith("Z") else text + parsed = datetime.datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + return None + return parsed.astimezone(datetime.UTC) + + +def _resolve_exported_event_timestamp(event: dict[str, object]) -> datetime.datetime | None: + precise_timestamp = _parse_precise_utc_timestamp(event.get("timestamp_utc")) + if precise_timestamp is not None: + return precise_timestamp + return _parse_precise_utc_timestamp(event.get("timestamp")) + + +def _load_user_public_key_pem_bytes(configured_value: str) -> bytes: + stripped = configured_value.strip() + if not stripped: + return b"" + if stripped.startswith("-----BEGIN"): + return f"{stripped.rstrip()}\n".encode() + with open(stripped, "rb") as key_file: + return key_file.read() + + +def _openssl_public_key_fingerprint_sha256(*, pem_bytes: bytes) -> str: + with tempfile.TemporaryDirectory() as tmpdir: + public_key_path = f"{tmpdir}/public-key.pem" + with open(public_key_path, "wb") as public_key_file: + public_key_file.write(pem_bytes) + + completed = subprocess.run( + ["openssl", "pkey", "-pubin", "-inform", "PEM", "-outform", "DER", "-in", public_key_path], + check=False, + capture_output=True, + ) + if completed.returncode != 0: + stderr = completed.stderr.decode("utf-8", errors="replace").strip() + raise ValueError(f"OpenSSL failed to parse public key: {stderr or 'unknown error'}") + return hashlib.sha256(completed.stdout).hexdigest() + + +def _verify_signature_with_public_key(*, pem_bytes: bytes, signature_bytes: bytes, canonical_bytes: bytes) -> bool: + with tempfile.TemporaryDirectory() as tmpdir: + public_key_path = f"{tmpdir}/public-key.pem" + signature_path = f"{tmpdir}/signature.der" + message_path = f"{tmpdir}/message.bin" + with open(public_key_path, "wb") as public_key_file: + public_key_file.write(pem_bytes) + with open(signature_path, "wb") as signature_file: + signature_file.write(signature_bytes) + with open(message_path, "wb") as message_file: + message_file.write(canonical_bytes) + + completed = subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-verify", + public_key_path, + "-signature", + signature_path, + message_path, + ], + check=False, + capture_output=True, + ) + if completed.returncode != 0: + return False + return b"Verified OK" in completed.stdout + + +def _resolve_trusted_public_key_fingerprint(*, trusted_pem_value: str, trusted_fingerprint_value: str) -> str: + trusted_pem_bytes = _load_user_public_key_pem_bytes(trusted_pem_value) + configured_fingerprint = _normalized_sha256_hex(trusted_fingerprint_value) + pem_fingerprint = "" + if trusted_pem_bytes: + pem_fingerprint = _openssl_public_key_fingerprint_sha256(pem_bytes=trusted_pem_bytes) + + if configured_fingerprint and pem_fingerprint and configured_fingerprint != pem_fingerprint: + raise ValueError("trusted public key fingerprint does not match trusted public key input") + + return configured_fingerprint or pem_fingerprint + + +def _decode_rekor_entry(*, rekor_entry_url: str) -> tuple[dict[str, object], dict[str, object]]: + with _urllib_request.urlopen(rekor_entry_url, timeout=10) as _resp: + wrapper_json = json.loads(_resp.read().decode("utf-8")) + + if not isinstance(wrapper_json, dict) or len(wrapper_json) != 1: + raise ValueError( + "unexpected Rekor response shape " + f"({len(wrapper_json) if isinstance(wrapper_json, dict) else type(wrapper_json).__name__} keys)" + ) + + wrapper_entry = next(iter(wrapper_json.values())) + if not isinstance(wrapper_entry, dict): + raise ValueError("invalid Rekor entry object") + + body_b64 = wrapper_entry.get("body") + if not isinstance(body_b64, str) or not body_b64.strip(): + raise ValueError("missing Rekor body field") + + body_json = json.loads(base64.b64decode(body_b64).decode("utf-8")) + if not isinstance(body_json, dict): + raise ValueError("invalid decoded body") + + return wrapper_entry, body_json + + +def _verify_online_rekor_entry( + *, + rekor_entry_url: str, + expected_digest: str, + canonical_bytes: bytes, + trusted_pem_value: str, + trusted_fingerprint_value: str, +) -> dict[str, object]: + wrapper_entry, body_json = _decode_rekor_entry(rekor_entry_url=rekor_entry_url) + + spec = body_json.get("spec") + if not isinstance(spec, dict): + raise ValueError("missing spec") + data = spec.get("data") + if not isinstance(data, dict): + raise ValueError("missing data section") + hash_data = data.get("hash") + if not isinstance(hash_data, dict): + raise ValueError("missing hash section") + + rekor_digest = _normalized_sha256_hex(hash_data.get("value")) + + signature = spec.get("signature") + if not isinstance(signature, dict): + raise ValueError("missing signature section") + signature_b64 = signature.get("content") + if not isinstance(signature_b64, str) or not signature_b64.strip(): + raise ValueError("missing signature content") + signature_bytes = base64.b64decode(signature_b64) + + public_key = signature.get("publicKey") + if not isinstance(public_key, dict): + raise ValueError("missing publicKey section") + public_key_b64 = public_key.get("content") + if not isinstance(public_key_b64, str) or not public_key_b64.strip(): + raise ValueError("missing publicKey content") + embedded_public_key_pem = base64.b64decode(public_key_b64) + embedded_public_key_sha256 = _openssl_public_key_fingerprint_sha256(pem_bytes=embedded_public_key_pem) + resolved_trusted_fingerprint = _resolve_trusted_public_key_fingerprint( + trusted_pem_value=trusted_pem_value, + trusted_fingerprint_value=trusted_fingerprint_value, + ) + integrated_time_value = wrapper_entry.get("integratedTime") + if integrated_time_value in (None, ""): + raise ValueError("missing Rekor integratedTime") + try: + integrated_time = datetime.datetime.fromtimestamp(int(integrated_time_value), tz=datetime.UTC) + except (TypeError, ValueError, OSError) as exc: + raise ValueError("invalid Rekor integratedTime") from exc + + return { + "rekor_digest": rekor_digest, + "digest_matches": rekor_digest == expected_digest, + "signature_valid": _verify_signature_with_public_key( + pem_bytes=embedded_public_key_pem, + signature_bytes=signature_bytes, + canonical_bytes=canonical_bytes, + ), + "embedded_public_key_sha256": embedded_public_key_sha256, + "trusted_public_key_sha256": resolved_trusted_fingerprint, + "trusted_configured": bool(resolved_trusted_fingerprint), + "signer_trusted": bool(resolved_trusted_fingerprint) + and embedded_public_key_sha256 == resolved_trusted_fingerprint, + "integrated_time": integrated_time, + "integrated_time_utc": _format_utc(integrated_time), + } + + def verify_rekor_attestations(*, audit_data: dict[str, object], verify_online: bool) -> tuple[bool, bool]: print("Rekor Attestation Verification") print("=" * 60) @@ -93,48 +445,30 @@ def verify_rekor_attestations(*, audit_data: dict[str, object], verify_online: b continue try: - with _urllib_request.urlopen(rekor_entry_url, timeout=10) as _resp: - wrapper_json = json.loads(_resp.read().decode("utf-8")) - - if not isinstance(wrapper_json, dict) or len(wrapper_json) != 1: - raise ValueError( - "unexpected Rekor response shape " - f"({len(wrapper_json) if isinstance(wrapper_json, dict) else type(wrapper_json).__name__} keys)" + online_result = _verify_online_rekor_entry( + rekor_entry_url=rekor_entry_url, + expected_digest=expected_digest, + canonical_bytes=_canonical_bytes(event_type=event.get("event_type"), payload=event.get("payload")), + trusted_pem_value="", + trusted_fingerprint_value="", + ) + if online_result["digest_matches"]: + print(" ✓ online inclusion: PASS (digest-scoped Rekor entry matches exported canonical digest)") + else: + print( + " ✗ online inclusion: FAIL " + f"(rekor={online_result['rekor_digest']} expected={expected_digest})" ) - - wrapper_entry = next(iter(wrapper_json.values())) - if not isinstance(wrapper_entry, dict): - raise ValueError("invalid Rekor entry object") - - body_b64 = wrapper_entry.get("body") - if not isinstance(body_b64, str) or not body_b64.strip(): - raise ValueError("missing Rekor body field") - - body_json = json.loads(base64.b64decode(body_b64).decode("utf-8")) - if not isinstance(body_json, dict): - raise ValueError("invalid decoded body") - - spec = body_json.get("spec") - if not isinstance(spec, dict): - raise ValueError("missing spec") - data = spec.get("data") - if not isinstance(data, dict): - raise ValueError("missing data section") - hash_data = data.get("hash") - if not isinstance(hash_data, dict): - raise ValueError("missing hash section") - - rekor_digest = str(hash_data.get("value") or "").strip().lower() - if rekor_digest and rekor_digest == expected_digest: - print(" ✓ online: PASS (Rekor hash matches)") + all_pass = False + if online_result["signature_valid"]: + print(" ✓ online signature: PASS (Rekor entry signature validates)") else: - print(f" ✗ online: FAIL (rekor={rekor_digest} expected={expected_digest})") + print(" ✗ online signature: FAIL") all_pass = False - - integrated_time_raw = wrapper_entry.get("integratedTime") - if integrated_time_raw is not None: - ts = datetime.datetime.fromtimestamp(int(integrated_time_raw), tz=datetime.UTC) - print(f" rekor timestamp: {ts.isoformat()}") + print( + " ! signer trust scope: generic event checks do not promote trust; " + "v2 election definition trust is evaluated separately." + ) except Exception as exc: print(f" Warning: online check failed ({type(exc).__name__}: {exc})") @@ -144,6 +478,177 @@ def verify_rekor_attestations(*, audit_data: dict[str, object], verify_online: b return found_any, all_pass +def evaluate_v2_election_definition( + *, + audit_data: dict[str, object], + verify_online: bool, + trusted_public_key_pem: str | None = None, + trusted_public_key_sha256: str | None = None, + rekor_time_tolerance_seconds: int = rekor_integrated_time_tolerance_seconds, +) -> dict[str, object]: + audit_log = audit_data.get("audit_log") + if not isinstance(audit_log, list): + return {"status": "invalid", "reason": "public-audit.json missing audit_log list"} + + election_started = None + for event in audit_log: + if isinstance(event, dict) and str(event.get("event_type") or "") == "election_started": + election_started = event + break + + if election_started is None: + return {"status": "not_applicable"} + + payload = election_started.get("payload") + timestamping = election_started.get("timestamping") + if not isinstance(payload, dict): + return {"status": "invalid", "reason": "election_started payload must be an object"} + if not isinstance(timestamping, dict): + return {"status": "invalid", "reason": "election_started missing timestamping metadata"} + if int(payload.get("chain_version") or 1) != 2: + return {"status": "not_applicable"} + if timestamping.get("canonical_message_version") != 1: + return {"status": "invalid", "reason": "election_started canonical_message_version mismatch"} + + expected_digest = str(timestamping.get("message_digest_hex") or "").strip().lower() + canonical_bytes = _canonical_bytes(event_type=election_started.get("event_type"), payload=payload) + computed_digest = hashlib.sha256(canonical_bytes).hexdigest() + if computed_digest != expected_digest: + return {"status": "invalid", "reason": "election_started digest verification failed"} + + manifest = payload.get("config_manifest") + if not isinstance(manifest, dict): + return {"status": "invalid", "reason": "election_started missing config_manifest"} + try: + payload_manifest_version = _require_supported_manifest_version( + value=payload.get("config_manifest_version"), + label="election_started payload", + ) + manifest_version = _require_supported_manifest_version( + value=manifest.get("version"), + label="config_manifest", + ) + except ValueError as exc: + return {"status": "invalid", "reason": str(exc)} + if payload_manifest_version != manifest_version: + return { + "status": "invalid", + "reason": "config_manifest_version mismatch between election_started payload and config_manifest", + } + try: + _validate_manifest_v1_schema(manifest=manifest) + except ValueError as exc: + return {"status": "invalid", "reason": str(exc)} + + election_payload = manifest.get("election") + if not isinstance(election_payload, dict): + return {"status": "invalid", "reason": "config_manifest missing election section"} + + try: + election_id = int(election_payload.get("id")) + except (TypeError, ValueError): + return {"status": "invalid", "reason": "config_manifest election.id must be an integer"} + + manifest_digest = hashlib.sha256(_canonical_manifest_bytes(manifest)).hexdigest() + exported_manifest_digest = str(payload.get("config_manifest_sha256") or "").strip().lower() + if manifest_digest != exported_manifest_digest: + return {"status": "invalid", "reason": "config_manifest_sha256 mismatch"} + + anchor_hash = _compute_chain_anchor_hash( + election_id=election_id, + config_manifest_sha256=manifest_digest, + ) + exported_genesis_hash = _resolve_payload_genesis_hash(payload=payload, label="election_started payload") + if anchor_hash != exported_genesis_hash: + return {"status": "invalid", "reason": "genesis_hash mismatch"} + + result = { + "status": "untrusted_local_only", + "chain_version": 2, + "config_manifest_version": payload_manifest_version, + "config_manifest_sha256": manifest_digest, + "genesis_hash": anchor_hash, + } + + if not verify_online: + return result + + rekor_entry_url = str(timestamping.get("rekor_entry_url") or "").strip() + if not rekor_entry_url: + return {"status": "invalid", "reason": "missing rekor_entry_url for online verification"} + + if trusted_public_key_pem is None and trusted_public_key_sha256 is None: + resolved_trusted_public_key_pem = globals()["trusted_public_key_pem"] + resolved_trusted_public_key_sha256 = globals()["trusted_public_key_sha256"] + else: + resolved_trusted_public_key_pem = trusted_public_key_pem or "" + resolved_trusted_public_key_sha256 = trusted_public_key_sha256 or "" + + try: + online_result = _verify_online_rekor_entry( + rekor_entry_url=rekor_entry_url, + expected_digest=expected_digest, + canonical_bytes=canonical_bytes, + trusted_pem_value=resolved_trusted_public_key_pem, + trusted_fingerprint_value=resolved_trusted_public_key_sha256, + ) + except Exception as exc: + return {"status": "invalid", "reason": f"online Rekor verification failed: {type(exc).__name__}: {exc}"} + + if not online_result["digest_matches"]: + return { + "status": "invalid", + "reason": f"Rekor inclusion digest mismatch: {online_result['rekor_digest']}", + } + + result["embedded_public_key_sha256"] = str(online_result["embedded_public_key_sha256"]) + result["rekor_integrated_time"] = str(online_result["integrated_time_utc"]) + + if not online_result["signature_valid"]: + return {"status": "invalid", "reason": "Rekor signature verification failed"} + + if not online_result["trusted_configured"]: + result["reason"] = "expected attestation signer PEM or fingerprint is not configured" + return result + + result["trusted_public_key_sha256"] = str(online_result["trusted_public_key_sha256"]) + if not online_result["signer_trusted"]: + return { + "status": "invalid", + "reason": ( + "embedded attestation signer fingerprint does not match trusted attestation signer " + f"(embedded={online_result['embedded_public_key_sha256']} " + f"trusted={online_result['trusted_public_key_sha256']})" + ), + } + + exported_event_timestamp = _resolve_exported_event_timestamp(election_started) + if exported_event_timestamp is None: + result["reason"] = ( + "exported audit timestamp lacks sub-day precision; " + "Rekor timestamp consistency was not checked" + ) + return result + + timestamp_delta_seconds = abs( + int((online_result["integrated_time"] - exported_event_timestamp).total_seconds()) + ) + result["event_timestamp_utc"] = _format_utc(exported_event_timestamp) + result["rekor_timestamp_delta_seconds"] = timestamp_delta_seconds + result["rekor_time_tolerance_seconds"] = int(rekor_time_tolerance_seconds) + if timestamp_delta_seconds > int(rekor_time_tolerance_seconds): + return { + "status": "invalid", + "reason": ( + "Rekor integrated timestamp is outside the allowed tolerance " + f"(delta={timestamp_delta_seconds}s tolerance={int(rekor_time_tolerance_seconds)}s)" + ), + } + + result["status"] = "valid" + return result + + if __name__ == "__main__": with open(audit_file, encoding="utf-8") as f: audit_data = json.load(f) @@ -156,13 +661,60 @@ def verify_rekor_attestations(*, audit_data: dict[str, object], verify_online: b verify_online=verify_rekor_online, ) + v2_result = evaluate_v2_election_definition( + audit_data=audit_data, + verify_online=verify_rekor_online, + ) + + if v2_result.get("status") != "not_applicable": + print() + print("Election Definition Verification") + print("=" * 60) + print(f"status: {v2_result.get('status')}") + if "reason" in v2_result: + print(f"reason: {v2_result['reason']}") + if v2_result["reason"] == "expected attestation signer PEM or fingerprint is not configured": + print( + "trust_configuration: no trusted attestation signer is configured; " + "set trusted_public_key_pem / trusted_public_key_sha256 for the environment " + "whose attestation signer you expect" + ) + if "chain_version" in v2_result: + print(f"chain_version: {v2_result['chain_version']}") + print(f"config_manifest_version: {v2_result['config_manifest_version']}") + print(f"config_manifest_sha256: {v2_result['config_manifest_sha256']}") + print(f"genesis_hash: {v2_result['genesis_hash']}") + if "embedded_public_key_sha256" in v2_result: + print(f"embedded_public_key_sha256: {v2_result['embedded_public_key_sha256']}") + if "trusted_public_key_sha256" in v2_result: + print(f"trusted_public_key_sha256: {v2_result['trusted_public_key_sha256']}") + if "event_timestamp_utc" in v2_result: + print(f"event_timestamp_utc: {v2_result['event_timestamp_utc']}") + if "rekor_integrated_time" in v2_result: + print(f"rekor_integrated_time: {v2_result['rekor_integrated_time']}") + if "rekor_timestamp_delta_seconds" in v2_result: + print(f"rekor_timestamp_delta_seconds: {v2_result['rekor_timestamp_delta_seconds']}") + print(f"rekor_time_tolerance_seconds: {v2_result['rekor_time_tolerance_seconds']}") + if not found_any: + if v2_result.get("status") == "invalid": + raise SystemExit(1) + if v2_result.get("status") == "untrusted_local_only": + raise SystemExit(2) raise SystemExit(0) print() - if all_pass: + if v2_result.get("status") == "invalid": + print("✗ Election definition verification failed.") + raise SystemExit(1) + + if all_pass and v2_result.get("status") not in {"untrusted_local_only"}: print("✓ All digest checks passed.") raise SystemExit(0) + if all_pass and v2_result.get("status") == "untrusted_local_only": + print("! Local digest checks passed, but Rekor inclusion was not independently confirmed.") + raise SystemExit(2) + print("✗ One or more digest checks failed.") raise SystemExit(1) diff --git a/astra_app/core/static/verify-ballot-chain.py b/astra_app/core/static/verify-ballot-chain.py index 95e96a52..c10210cb 100644 --- a/astra_app/core/static/verify-ballot-chain.py +++ b/astra_app/core/static/verify-ballot-chain.py @@ -3,9 +3,11 @@ Verify the public ballot chain (local check) This script checks that the downloaded ballots file forms an unbroken SHA-256 chain -and that the computed current ledger hash matches the one published for the election. -It also checks whether your ballot receipt code appears in the file, and (optionally) -whether the previous ledger hash matches your receipt. +and that the computed final election chain head matches the one published for the election. +For chain_version 2 elections, it also requires the matching public-audit.json file +from the same publication pair so it can verify the manifest-to-anchor binding. +It also checks whether your ballot receipt code appears in the file and, when provided, +verifies the receipt-time previous/current ledger hashes against the located ballot row. This script runs locally and does not contact the election server. @@ -26,18 +28,59 @@ # Submission nonce: find this in the vote receipt email or in Advanced receipt info on the ballot verification page. # This script does not need it, but keeping it here makes it easier to confirm you copied the right receipt. submission_nonce = "your-submission-nonce" # Optional -# Previous ledger hash: find this in the vote receipt email or in Advanced receipt info on the ballot verification page. -previous_ledger_hash = "previous-ledger-hash-from-receipt" # Optional -# Current ledger hash: find this on the election page after the election closes, or in the public-ballots.json export chain_head field. -current_ledger_hash = "current-ledger-hash-from-election-page" +# Receipt previous ledger hash: find this in the vote receipt email or in Advanced receipt info on the ballot verification page. +receipt_previous_ledger_hash = "previous-ledger-hash-from-receipt" # Optional +# Receipt current ledger hash: find this in the vote receipt email or in Advanced receipt info on the ballot verification page. +receipt_current_ledger_hash = "current-ledger-hash-from-receipt" # Optional +# Final election chain head / current ledger hash: find this on the election page after the election closes, +# or in the public-ballots.json export chain_head field. +final_election_chain_head = "current-ledger-hash-from-election-page" # Optional # Download public-ballots.json from the election page export and keep it next to this script. ballots_file = "public-ballots.json" +# For v2 elections, download public-audit.json from the same published pair and keep it next to this script. +audit_file = "public-audit.json" +# This script does not contact Rekor directly. Set this to True if you also want the CLI output to remind +# you to run verify-audit-log.py with online verification for the matching public-audit.json file. +verify_rekor_online = False # ===== END OF USER INPUT ===== +CONFIG_MANIFEST_VERSION = 1 + import hashlib # noqa: E402 import json # noqa: E402 +from typing import cast # noqa: E402 + + +def _clean_optional_text(value: object) -> str: + return str(value or "").strip() + + +def _require_int(value: object, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise ValueError(f"{label} must be an integer") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label} must be an integer") from exc + + +def _resolve_user_hash_inputs( + *, + previous_ledger_hash: str, + current_ledger_hash: str, + receipt_previous_ledger_hash: str, + receipt_current_ledger_hash: str, + final_election_chain_head: str, +) -> dict[str, str]: + return { + "receipt_previous_ledger_hash": _clean_optional_text(receipt_previous_ledger_hash) + or _clean_optional_text(previous_ledger_hash), + "receipt_current_ledger_hash": _clean_optional_text(receipt_current_ledger_hash), + "final_election_chain_head": _clean_optional_text(final_election_chain_head) + or _clean_optional_text(current_ledger_hash), + } def compute_genesis_hash(election_id: int) -> str: @@ -46,12 +89,29 @@ def compute_genesis_hash(election_id: int) -> str: return hashlib.sha256(data).hexdigest() +def compute_chain_anchor_hash(*, election_id: int, config_manifest_sha256: str) -> str: + data = ( + f"election-v2:{election_id}:{config_manifest_sha256}. " + "alex estuvo aquí, dejándose el alma." + ).encode() + return hashlib.sha256(data).hexdigest() + + +def canonical_manifest_bytes(manifest: dict[str, object]) -> bytes: + return json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + + def compute_chain_hash(*, previous_chain_hash: str, ballot_hash: str) -> str: """Next chain hash (must match core.tokens.election_chain_next_hash).""" return hashlib.sha256(f"{previous_chain_hash}:{ballot_hash}".encode()).hexdigest() -def reconstruct_chain_order(*, ballots: list[dict[str, object]], genesis_hash: str) -> list[dict[str, object]]: +def reconstruct_chain_order( + *, + ballots: list[dict[str, object]], + genesis_hash: str, + origin_label: str = "genesis", +) -> list[dict[str, object]]: """Return ballots in chain order without relying on export ordering. Raises ValueError if the export is inconsistent (forks, cycles, disconnected sets, @@ -68,7 +128,9 @@ def reconstruct_chain_order(*, ballots: list[dict[str, object]], genesis_hash: s by_previous[previous] = row if ballots and genesis_hash not in by_previous: - raise ValueError("missing genesis linkage: no ballot references the election genesis hash") + if origin_label == "genesis": + raise ValueError("missing genesis linkage: no ballot references the election genesis hash") + raise ValueError(f"missing {origin_label} linkage: no ballot references the {origin_label}") ordered: list[dict[str, object]] = [] visited_chain_hashes: set[str] = set() @@ -107,14 +169,579 @@ def reconstruct_chain_order(*, ballots: list[dict[str, object]], genesis_hash: s return ordered + +def _require_matching_metadata(*, ballots_export: dict[str, object], audit_export: dict[str, object], key: str) -> str: + ballots_value = str(ballots_export.get(key) or "").strip() + audit_value = str(audit_export.get(key) or "").strip() + if ballots_value != audit_value: + raise ValueError(f"{key} mismatch between public-ballots.json and public-audit.json") + return ballots_value + + +def _resolve_export_genesis_hash(*, export: dict[str, object], label: str) -> str: + canonical_genesis = _clean_optional_text(export.get("genesis_hash")).lower() + legacy_anchor = _clean_optional_text(export.get("chain_anchor_hash")).lower() + legacy_root = _clean_optional_text(export.get("chain_root_hash")).lower() + if canonical_genesis and legacy_anchor and canonical_genesis != legacy_anchor: + raise ValueError(f"{label} chain_anchor_hash alias does not match genesis_hash") + if canonical_genesis and legacy_root and canonical_genesis != legacy_root: + raise ValueError(f"{label} chain_root_hash alias does not match genesis_hash") + if legacy_anchor and legacy_root and legacy_anchor != legacy_root: + raise ValueError(f"{label} chain_root_hash alias does not match chain_anchor_hash") + resolved = canonical_genesis or legacy_anchor or legacy_root + if not resolved: + raise ValueError(f"{label} missing genesis_hash") + return resolved + + +def _resolve_payload_genesis_hash(*, payload: dict[str, object], label: str) -> str: + return _resolve_export_genesis_hash(export=payload, label=label) + + +def _publication_bundle_published_at(*, export: dict[str, object], label: str) -> str: + bundle = export.get("publication_bundle") + if bundle is None: + return "" + if not isinstance(bundle, dict): + raise ValueError(f"{label} publication_bundle must be an object") + published_at = str(bundle.get("published_at") or "").strip() + if not published_at: + raise ValueError(f"{label} publication_bundle missing published_at") + return published_at + + +def _require_supported_manifest_version(*, value: object, label: str) -> int: + if value in (None, ""): + raise ValueError(f"{label} missing config_manifest_version") + manifest_version = _require_int(value, label=f"{label} config_manifest_version") + if manifest_version != CONFIG_MANIFEST_VERSION: + raise ValueError( + f"{label} config_manifest_version must be {CONFIG_MANIFEST_VERSION} for v2 verification" + ) + return manifest_version + + +def _require_manifest_dict_field(*, data: dict[str, object], field_name: str, label: str) -> dict[str, object]: + value = data.get(field_name) + if not isinstance(value, dict): + raise ValueError(f"{label} missing {field_name} section") + return value + + +def _require_manifest_list_field(*, data: dict[str, object], field_name: str, label: str) -> list[object]: + value = data.get(field_name) + if not isinstance(value, list): + raise ValueError(f"{label} {field_name} must be a list") + return value + + +def _require_manifest_int(*, data: dict[str, object], field_name: str, label: str) -> int: + value = data.get(field_name) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} {field_name} must be an integer") + return value + + +def _require_manifest_str(*, data: dict[str, object], field_name: str, label: str) -> str: + value = data.get(field_name) + if not isinstance(value, str): + raise ValueError(f"{label} {field_name} must be a string") + return value + + +def _validate_manifest_v1_schema(*, manifest: dict[str, object]) -> None: + election = _require_manifest_dict_field(data=manifest, field_name="election", label="config_manifest") + tally_rule = _require_manifest_dict_field(data=manifest, field_name="tally_rule", label="config_manifest") + candidates = _require_manifest_list_field(data=manifest, field_name="candidates", label="config_manifest") + exclusion_groups = _require_manifest_list_field( + data=manifest, + field_name="exclusion_groups", + label="config_manifest", + ) + + _require_manifest_int(data=election, field_name="id", label="config_manifest election") + for field_name in ( + "name", + "start_datetime", + "eligible_group_cn", + ): + _require_manifest_str(data=election, field_name=field_name, label="config_manifest election") + for field_name in ("number_of_seats", "quorum"): + _require_manifest_int(data=election, field_name=field_name, label="config_manifest election") + + for field_name in ("algorithm", "algorithm_version", "spec_identity", "epsilon"): + _require_manifest_str(data=tally_rule, field_name=field_name, label="config_manifest tally_rule") + _require_manifest_int(data=tally_rule, field_name="max_iterations", label="config_manifest tally_rule") + + for index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + raise ValueError(f"config_manifest candidates[{index}] must be an object") + _require_manifest_int(data=candidate, field_name="id", label=f"config_manifest candidates[{index}]") + for field_name in ( + "freeipa_username", + "nominated_by", + "tiebreak_uuid", + ): + _require_manifest_str( + data=candidate, + field_name=field_name, + label=f"config_manifest candidates[{index}]", + ) + + for index, group in enumerate(exclusion_groups): + if not isinstance(group, dict): + raise ValueError(f"config_manifest exclusion_groups[{index}] must be an object") + for field_name in ("public_id", "name"): + _require_manifest_str( + data=group, + field_name=field_name, + label=f"config_manifest exclusion_groups[{index}]", + ) + _require_manifest_int( + data=group, + field_name="max_elected", + label=f"config_manifest exclusion_groups[{index}]", + ) + candidate_ids = _require_manifest_list_field( + data=group, + field_name="candidate_ids", + label=f"config_manifest exclusion_groups[{index}]", + ) + for candidate_index, candidate_id in enumerate(candidate_ids): + if isinstance(candidate_id, bool) or not isinstance(candidate_id, int): + raise ValueError( + "config_manifest exclusion_groups" + f"[{index}] candidate_ids[{candidate_index}] must be an integer" + ) + + +def _find_receipt_row(*, ordered: list[dict[str, object]], ballot_receipt_code: str) -> dict[str, object] | None: + if not str(ballot_receipt_code or "").strip(): + return None + + for row in ordered: + if str(row.get("ballot_hash") or "") == ballot_receipt_code: + return row + return None + + +def _raise_election_id_mismatch() -> None: + raise ValueError( + "election_id mismatch: exports do not match the requested election_id; use the " + "public-ballots.json/public-audit.json pair for this election." + ) + + +def _raise_missing_public_audit_file_for_v2() -> None: + raise ValueError( + "public-audit.json missing for v2 verification; download the matching public-audit.json " + "from the same publication bundle and rerun." + ) + + +def _raise_chain_version_mismatch() -> None: + raise ValueError( + "chain_version mismatch between public-ballots.json and public-audit.json; use the " + "matching public-ballots/public-audit export pair for this election." + ) + + +def _raise_missing_v2_audit_log() -> None: + raise ValueError( + "public-audit.json must include audit_log for v2 verification; use the matching " + "public-audit.json exported with this public-ballots.json." + ) + + +def _raise_v2_chain_root_kind_mismatch() -> None: + raise ValueError( + "v2 exports must use chain_root_kind=config_anchor_v2; use the matching " + "public-ballots/public-audit export pair for this election." + ) + + +def _raise_publication_bundle_published_at_mismatch() -> None: + raise ValueError( + "publication_bundle.published_at mismatch between public-ballots.json and " + "public-audit.json; use files from the same published bundle." + ) + + +def _raise_chain_head_mismatch() -> None: + raise ValueError( + "chain head mismatch: reconstructed chain does not match exported chain_head; " + "re-download the published export(s) and rerun verification." + ) + + +def _raise_chain_anchor_mismatch() -> None: + raise ValueError( + "genesis_hash mismatch: computed genesis hash does not match exported genesis_hash; " + "use the matching public-audit.json and config manifest for this election." + ) + + +def _raise_missing_v2_election_started_event() -> None: + raise ValueError( + "public-audit.json missing election_started event for v2 verification; use the matching " + "public-audit.json exported with this public-ballots.json." + ) + +def _raise_current_ledger_hash_mismatch() -> None: + raise ValueError( + "Entered current ledger hash does not match public-ballots.json chain_head. " + "You likely pasted a receipt-level current ledger hash; use the election page hash or " + "the export chain_head value." + ) + + +def _raise_receipt_previous_ledger_hash_mismatch() -> None: + raise ValueError( + "Entered receipt previous ledger hash does not match the located ballot row previous_chain_hash. " + "Check the vote receipt's previous ledger hash for this ballot and rerun." + ) + + +def _raise_receipt_current_ledger_hash_mismatch() -> None: + raise ValueError( + "Entered receipt current ledger hash does not match the located ballot row chain_hash. " + "Use the receipt-time current ledger hash for your ballot, not the final election chain head." + ) + + +def _rekor_guidance(*, verify_rekor_online: bool) -> str: + if verify_rekor_online: + return ( + "verify_rekor_online=True requests the audit/Rekor step, but verify-ballot-chain.py " + "only verifies ballot-chain integrity. Run verify-audit-log.py with the same " + "public-audit.json and verify_online=True to perform the online Rekor check." + ) + return ( + "Local-only success: run verify-audit-log.py with the matching public-audit.json if you " + "want a separate audit/Rekor verification step. That audit-only step does not by itself " + "prove ballot-ledger integrity." + ) + + +def _verify_receipt_hash_inputs( + *, + your_row: dict[str, object] | None, + receipt_previous_ledger_hash: str, + receipt_current_ledger_hash: str, +) -> None: + if your_row is None: + return + + exported_previous = _clean_optional_text(your_row.get("previous_chain_hash")) + if receipt_previous_ledger_hash and receipt_previous_ledger_hash != exported_previous: + _raise_receipt_previous_ledger_hash_mismatch() + + exported_current = _clean_optional_text(your_row.get("chain_hash")) + if receipt_current_ledger_hash and receipt_current_ledger_hash != exported_current: + _raise_receipt_current_ledger_hash_mismatch() + + +def verify_export_bundle( + *, + ballots_export: dict[str, object], + audit_export: dict[str, object], + election_id: int, + ballot_receipt_code: str, + previous_ledger_hash: str, + current_ledger_hash: str, + receipt_previous_ledger_hash: str = "", + receipt_current_ledger_hash: str = "", + final_election_chain_head: str = "", + verify_rekor_online: bool, +) -> dict[str, object]: + resolved_inputs = _resolve_user_hash_inputs( + previous_ledger_hash=previous_ledger_hash, + current_ledger_hash=current_ledger_hash, + receipt_previous_ledger_hash=receipt_previous_ledger_hash, + receipt_current_ledger_hash=receipt_current_ledger_hash, + final_election_chain_head=final_election_chain_head, + ) + + chain_version = _require_int(ballots_export.get("chain_version") or 1, label="public-ballots.json chain_version") + audit_chain_version = _require_int(audit_export.get("chain_version") or 1, label="public-audit.json chain_version") + if chain_version != audit_chain_version: + _raise_chain_version_mismatch() + + export_election_id = _require_matching_metadata( + ballots_export=ballots_export, + audit_export=audit_export, + key="election_id", + ) + if int(export_election_id or 0) != election_id: + _raise_election_id_mismatch() + + if chain_version == 1: + genesis = compute_genesis_hash(election_id) + ballots = ballots_export.get("ballots") + if not isinstance(ballots, list): + raise ValueError("public-ballots.json ballots must be a list") + ordered = reconstruct_chain_order(ballots=ballots, genesis_hash=genesis) + computed_head = ordered[-1]["chain_hash"] if ordered else genesis + expected_head = str(ballots_export.get("chain_head") or "").strip() + if ( + resolved_inputs["final_election_chain_head"] + and resolved_inputs["final_election_chain_head"] != expected_head + ): + _raise_current_ledger_hash_mismatch() + if computed_head != expected_head: + raise ValueError("chain head mismatch") + return {"status": "valid", "chain_head": computed_head} + + audit_log_obj = audit_export.get("audit_log") + if not isinstance(audit_log_obj, list): + _raise_missing_v2_audit_log() + audit_log = cast(list[object], audit_log_obj) + + chain_root_kind = _require_matching_metadata( + ballots_export=ballots_export, + audit_export=audit_export, + key="chain_root_kind", + ) + if chain_root_kind != "config_anchor_v2": + _raise_v2_chain_root_kind_mismatch() + + expected_head = _require_matching_metadata( + ballots_export=ballots_export, + audit_export=audit_export, + key="chain_head", + ) + ballots_genesis_hash = _resolve_export_genesis_hash(export=ballots_export, label="public-ballots.json") + audit_genesis_hash = _resolve_export_genesis_hash(export=audit_export, label="public-audit.json") + if ballots_genesis_hash != audit_genesis_hash: + raise ValueError("genesis_hash mismatch between public-ballots.json and public-audit.json") + genesis_hash = ballots_genesis_hash + exported_manifest_version = _require_supported_manifest_version( + value=_require_matching_metadata( + ballots_export=ballots_export, + audit_export=audit_export, + key="config_manifest_version", + ), + label="public exports", + ) + manifest_digest = _require_matching_metadata( + ballots_export=ballots_export, + audit_export=audit_export, + key="config_manifest_sha256", + ) + ballots_published_at = _publication_bundle_published_at( + export=ballots_export, + label="public-ballots.json", + ) + audit_published_at = _publication_bundle_published_at( + export=audit_export, + label="public-audit.json", + ) + publication_bundle_published_at = "" + if ballots_published_at or audit_published_at: + if not ballots_published_at or not audit_published_at or ballots_published_at != audit_published_at: + _raise_publication_bundle_published_at_mismatch() + publication_bundle_published_at = ballots_published_at + + election_started: dict[str, object] | None = None + for event in audit_log: + if isinstance(event, dict) and str(event.get("event_type") or "") == "election_started": + election_started = event + break + if election_started is None: + _raise_missing_v2_election_started_event() + resolved_election_started = cast(dict[str, object], election_started) + + payload = resolved_election_started.get("payload") + if not isinstance(payload, dict): + raise ValueError("election_started payload must be an object") + manifest = payload.get("config_manifest") + if not isinstance(manifest, dict): + raise ValueError("election_started payload missing config_manifest") + payload_manifest_version = _require_supported_manifest_version( + value=payload.get("config_manifest_version"), + label="election_started payload", + ) + manifest_version = _require_supported_manifest_version( + value=manifest.get("version"), + label="config_manifest", + ) + if payload_manifest_version != exported_manifest_version: + raise ValueError("config_manifest_version mismatch between public exports and election_started payload") + if manifest_version != exported_manifest_version: + raise ValueError("config_manifest_version mismatch between public exports and config_manifest") + _validate_manifest_v1_schema(manifest=manifest) + + computed_manifest_digest = hashlib.sha256(canonical_manifest_bytes(manifest)).hexdigest() + if computed_manifest_digest != manifest_digest: + raise ValueError("config_manifest_sha256 mismatch") + payload_manifest_digest = str(payload.get("config_manifest_sha256") or "").strip().lower() + if payload_manifest_digest != computed_manifest_digest: + raise ValueError("election_started payload config_manifest_sha256 mismatch") + + computed_anchor = compute_chain_anchor_hash( + election_id=election_id, + config_manifest_sha256=computed_manifest_digest, + ) + payload_genesis_hash = _resolve_payload_genesis_hash( + payload=payload, + label="election_started payload", + ) + if payload_genesis_hash != computed_anchor: + raise ValueError("election_started payload genesis_hash mismatch") + if genesis_hash != computed_anchor: + _raise_chain_anchor_mismatch() + + ballots = ballots_export.get("ballots") + if not isinstance(ballots, list): + raise ValueError("public-ballots.json ballots must be a list") + ordered = reconstruct_chain_order( + ballots=ballots, + genesis_hash=genesis_hash, + origin_label="genesis hash", + ) + computed_head = ordered[-1]["chain_hash"] if ordered else genesis_hash + if ( + resolved_inputs["final_election_chain_head"] + and resolved_inputs["final_election_chain_head"] != expected_head + ): + _raise_current_ledger_hash_mismatch() + if computed_head != expected_head: + _raise_chain_head_mismatch() + + your_row = _find_receipt_row(ordered=ordered, ballot_receipt_code=ballot_receipt_code) + _verify_receipt_hash_inputs( + your_row=your_row, + receipt_previous_ledger_hash=resolved_inputs["receipt_previous_ledger_hash"], + receipt_current_ledger_hash=resolved_inputs["receipt_current_ledger_hash"], + ) + + return { + "status": "untrusted_local_only", + "chain_head": computed_head, + "chain_root_kind": chain_root_kind, + "config_manifest_sha256": computed_manifest_digest, + "genesis_hash": computed_anchor, + "publication_bundle_published_at": publication_bundle_published_at, + "receipt_found": your_row is not None, + "receipt_previous_chain_hash": "" if your_row is None else str(your_row.get("previous_chain_hash") or ""), + "receipt_chain_hash": "" if your_row is None else str(your_row.get("chain_hash") or ""), + "rekor_online_requested": verify_rekor_online, + "rekor_guidance": _rekor_guidance(verify_rekor_online=verify_rekor_online), + } + + +def verify_public_ballot_export( + *, + ballots_export: dict[str, object], + audit_export: dict[str, object] | None, + election_id: int, + ballot_receipt_code: str, + previous_ledger_hash: str, + current_ledger_hash: str, + receipt_previous_ledger_hash: str = "", + receipt_current_ledger_hash: str = "", + final_election_chain_head: str = "", + verify_rekor_online: bool = False, +) -> dict[str, object]: + chain_version = _require_int(ballots_export.get("chain_version") or 1, label="public-ballots.json chain_version") + resolved_inputs = _resolve_user_hash_inputs( + previous_ledger_hash=previous_ledger_hash, + current_ledger_hash=current_ledger_hash, + receipt_previous_ledger_hash=receipt_previous_ledger_hash, + receipt_current_ledger_hash=receipt_current_ledger_hash, + final_election_chain_head=final_election_chain_head, + ) + + if chain_version == 1: + genesis = compute_genesis_hash(election_id) + export_election_id = str(ballots_export.get("election_id") or "").strip() + if export_election_id and int(export_election_id) != election_id: + _raise_election_id_mismatch() + + root_kind = str(ballots_export.get("chain_root_kind") or "").strip() + if root_kind and root_kind != "legacy_genesis": + raise ValueError("chain_root_kind mismatch") + + exported_genesis = _clean_optional_text(ballots_export.get("genesis_hash")).lower() + legacy_root_hash = _clean_optional_text(ballots_export.get("chain_root_hash")).lower() + if exported_genesis and exported_genesis != genesis: + raise ValueError("genesis_hash mismatch") + if legacy_root_hash and legacy_root_hash != genesis: + raise ValueError("chain_root_hash mismatch") + if exported_genesis and legacy_root_hash and exported_genesis != legacy_root_hash: + raise ValueError("chain_root_hash alias does not match genesis_hash") + + ballots = ballots_export.get("ballots") + if not isinstance(ballots, list): + raise ValueError("public-ballots.json ballots must be a list") + ordered = reconstruct_chain_order(ballots=ballots, genesis_hash=genesis) + expected_head = str(ballots_export.get("chain_head") or "").strip() + computed_head = ordered[-1]["chain_hash"] if ordered else genesis + if ( + resolved_inputs["final_election_chain_head"] + and resolved_inputs["final_election_chain_head"] != expected_head + ): + _raise_current_ledger_hash_mismatch() + if computed_head != expected_head: + _raise_chain_head_mismatch() + + your_row = None + for row in ordered: + if str(row.get("ballot_hash") or "") == ballot_receipt_code: + your_row = row + break + + _verify_receipt_hash_inputs( + your_row=your_row, + receipt_previous_ledger_hash=resolved_inputs["receipt_previous_ledger_hash"], + receipt_current_ledger_hash=resolved_inputs["receipt_current_ledger_hash"], + ) + + return { + "status": "valid", + "chain_version": 1, + "chain_root_kind": "legacy_genesis", + "genesis_hash": genesis, + "chain_head": computed_head, + "total_ballots": len(ordered), + "receipt_found": your_row is not None, + "receipt_previous_chain_hash": "" if your_row is None else str(your_row.get("previous_chain_hash") or ""), + "receipt_chain_hash": "" if your_row is None else str(your_row.get("chain_hash") or ""), + "rekor_online_requested": False, + "rekor_guidance": "", + } + + if audit_export is None: + _raise_missing_public_audit_file_for_v2() + resolved_audit_export = cast(dict[str, object], audit_export) + + result = verify_export_bundle( + ballots_export=ballots_export, + audit_export=resolved_audit_export, + election_id=election_id, + ballot_receipt_code=ballot_receipt_code, + previous_ledger_hash=previous_ledger_hash, + current_ledger_hash=current_ledger_hash, + receipt_previous_ledger_hash=resolved_inputs["receipt_previous_ledger_hash"], + receipt_current_ledger_hash=resolved_inputs["receipt_current_ledger_hash"], + final_election_chain_head=resolved_inputs["final_election_chain_head"], + verify_rekor_online=verify_rekor_online, + ) + ballots = ballots_export.get("ballots") + if not isinstance(ballots, list): + raise ValueError("public-ballots.json ballots must be a list") + result["chain_version"] = 2 + result["total_ballots"] = len(ballots) + return result + if __name__ == "__main__": with open(ballots_file, encoding="utf-8") as f: - export = json.load(f) + ballots_export = json.load(f) - if not isinstance(export, dict): + if not isinstance(ballots_export, dict): raise SystemExit("public-ballots.json must contain a JSON object") - export_election_id = export.get("election_id") + export_election_id = ballots_export.get("election_id") if export_election_id is not None: try: export_election_id = int(export_election_id) @@ -127,70 +754,75 @@ def reconstruct_chain_order(*, ballots: list[dict[str, object]], genesis_hash: s "Copy the election_id from the election page or the verify page." ) - genesis = compute_genesis_hash(election_id) - export_genesis = str(export.get("genesis_hash") or "").strip() - if export_genesis and export_genesis != genesis: - raise SystemExit( - "genesis hash mismatch: the export does not match the production genesis algorithm. " - f"computed={genesis} export={export_genesis}" + audit_export: dict[str, object] | None = None + if int(ballots_export.get("chain_version") or 1) == 2: + with open(audit_file, encoding="utf-8") as f: + loaded_audit = json.load(f) + if not isinstance(loaded_audit, dict): + raise SystemExit("public-audit.json must contain a JSON object") + audit_export = loaded_audit + + try: + result = verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=election_id, + ballot_receipt_code=ballot_receipt_code, + previous_ledger_hash="", + current_ledger_hash="", + receipt_previous_ledger_hash=receipt_previous_ledger_hash, + receipt_current_ledger_hash=receipt_current_ledger_hash, + final_election_chain_head=final_election_chain_head, + verify_rekor_online=verify_rekor_online, ) - - expected_head = str(export.get("chain_head") or "").strip() - if not expected_head: - raise SystemExit("public-ballots.json missing chain_head") - if str(current_ledger_hash or "").strip() and str(current_ledger_hash).strip() != expected_head: - raise SystemExit( - f"current ledger hash mismatch: election page={current_ledger_hash} export={expected_head}" - ) - - ballots_raw = export.get("ballots") - if not isinstance(ballots_raw, list): - raise SystemExit("public-ballots.json ballots must be a list") - ballots: list[dict[str, object]] = [] - for row in ballots_raw: - if not isinstance(row, dict): - raise SystemExit("public-ballots.json ballots entries must be objects") - ballots.append(row) - - ordered = reconstruct_chain_order(ballots=ballots, genesis_hash=genesis) - computed_head = ordered[-1]["chain_hash"] if ordered else genesis + except ValueError as exc: + raise SystemExit(str(exc)) from exc print("Ballot Chain Verification") print("=" * 60) print(f"Election ID: {election_id}") - print(f"Total ballots: {len(ordered)}") + print(f"Chain version: {result['chain_version']}") + print(f"Total ballots: {result['total_ballots']}") print(f"Ballot receipt code: {ballot_receipt_code}") + if result["chain_version"] == 1: + print(f"Chain root kind: {result['chain_root_kind']}") + print(f"Genesis hash: {result['genesis_hash']}") + else: + print(f"Genesis hash: {result['genesis_hash']}") + if str(result.get("config_manifest_sha256") or ""): + print(f"Election definition digest: {result['config_manifest_sha256']}") + print("For v2 elections, genesis_hash is the manifest-derived chain anchor.") + if str(result.get("publication_bundle_published_at") or ""): + print(f"Publication pair: {result['publication_bundle_published_at']}") + print(f"Verified path: {result['genesis_hash']} -> {result['chain_head']}") print() - your_row = None - for row in ordered: - if str(row.get("ballot_hash") or "") == ballot_receipt_code: - your_row = row - break - - if your_row is not None: + if result["receipt_found"]: print("→ Your ballot receipt code appears in the public ballots export.") - exported_previous = str(your_row.get("previous_chain_hash") or "") - print(f" Exported previous ledger hash: {exported_previous}") - if str(previous_ledger_hash or "").strip(): - if exported_previous == previous_ledger_hash: - print(" ✓ Previous ledger hash matches your receipt.") - else: - print(" ✗ Previous ledger hash does not match your receipt.") - print(f" Receipt previous ledger hash: {previous_ledger_hash}") - print(f" Exported current ledger hash: {your_row.get('chain_hash')}") + exported_previous = str(result.get("receipt_previous_chain_hash") or "") + print(f" Exported receipt previous ledger hash: {exported_previous}") + if receipt_previous_ledger_hash: + print(" ✓ Receipt previous ledger hash matches your receipt.") + exported_receipt_current = str(result.get("receipt_chain_hash") or "") + print(f" Exported receipt current ledger hash: {exported_receipt_current}") + if receipt_current_ledger_hash: + print(" ✓ Receipt current ledger hash matches your receipt.") print() else: print("✗ Your ballot receipt code was not found in the export.") print() - if computed_head != expected_head: - raise SystemExit(f"chain head mismatch: computed={computed_head} expected={expected_head}") + if result["chain_version"] == 1: + print("✓ Chain integrity verified: genesis hash -> chain head is a single, complete path") + else: + print("✓ Chain integrity verified: genesis hash -> chain head is a single, complete path") + print(f" Final election chain head: {result['chain_head']}") + if final_election_chain_head: + print(" ✓ Final election chain head matches public-ballots.json chain_head.") - print("✓ Chain integrity verified: genesis → head is a single, complete path") - print(f" Genesis hash: {genesis}") - print(f" Current ledger hash: {computed_head}") + if result["status"] == "untrusted_local_only": + print(f"! {result['rekor_guidance']}") - if your_row is None: + if not result["receipt_found"]: raise SystemExit(3) - raise SystemExit(0) + raise SystemExit(0 if result["status"] == "valid" else 2) diff --git a/astra_app/core/templated_email.py b/astra_app/core/templated_email.py index ab63737a..5f91fe75 100644 --- a/astra_app/core/templated_email.py +++ b/astra_app/core/templated_email.py @@ -278,7 +278,14 @@ def queue_templated_email( for src in sources for m in _REQUIRED_TEMPLATE_VAR_PATTERN.finditer(src) } - for var in ["ballot_hash", "nonce", "weight", "chain_hash", "previous_chain_hash"]: + for var in [ + "ballot_hash", + "nonce", + "weight", + "chain_hash", + "previous_chain_hash", + "config_manifest_sha256", + ]: if var not in rendered_vars: logger.warning( f"Vote receipt EmailTemplate is missing required variable: {var} (template={template_name})", diff --git a/astra_app/core/templates/core/ballot_verify.html b/astra_app/core/templates/core/ballot_verify.html index cdafe361..72e373dd 100644 --- a/astra_app/core/templates/core/ballot_verify.html +++ b/astra_app/core/templates/core/ballot_verify.html @@ -17,7 +17,7 @@ data-ballot-verify-api-url="{% url 'api-ballot-verify' %}" data-ballot-verify-hash-script-url="{% static 'verify-ballot-hash.py' %}" data-ballot-verify-chain-script-url="{% static 'verify-ballot-chain.py' %}" - data-ballot-verify-audit-script-url="{% static 'verify-audit-log.py' %}" + data-ballot-verify-audit-script-url="{% url 'verify-audit-log-download' %}" data-ballot-verify-election-detail-url-template="{{ election_detail_url_template }}" data-ballot-verify-audit-log-url-template="{{ election_audit_log_url_template }}" > diff --git a/astra_app/core/templates/core/election_algorithm.html b/astra_app/core/templates/core/election_algorithm.html index f1aba1cb..7daa31fc 100644 --- a/astra_app/core/templates/core/election_algorithm.html +++ b/astra_app/core/templates/core/election_algorithm.html @@ -18,7 +18,7 @@

Election algorithm

data-election-algorithm-runbook-url="https://github.com/AlmaLinux/astra/blob/{% if build_sha %}{{ build_sha }}{% else %}master{% endif %}/docs/runbooks/meek-stv-elections.md" data-election-algorithm-verify-ballot-hash-url="{% static 'verify-ballot-hash.py' %}" data-election-algorithm-verify-ballot-chain-url="{% static 'verify-ballot-chain.py' %}" - data-election-algorithm-verify-audit-log-url="{% static 'verify-audit-log.py' %}" + data-election-algorithm-verify-audit-log-url="{% url 'verify-audit-log-download' %}" >
@@ -85,15 +85,24 @@

How to verify results

  • The public ballots export contains hashes for each ballot and a chain hash that links ballots together.
  • Because each ballot's chain hash depends on the previous ballot's chain hash, modifying or removing a ballot breaks the chain.
  • -
  • The export also includes a per-election genesis hash that prevents mixing ballots from different elections.
  • +
  • For newer elections, the first link is anchored to a frozen election-definition digest instead of a legacy genesis hash.
  • +
  • That digest lets you confirm the election definition that was committed when voting opened.

3) Verify the published audit log and recount

  • The public audit log includes a per-iteration record of what happened (who met quota, who was elected/eliminated, tie-break reasons).
  • +
  • For v2 elections, the public audit log also includes the election-definition digest and the chain anchor committed at start.
  • An auditor can re-run the tally algorithm on the published ballots and confirm the same winners.
+

4) Verify the election definition committed at start

+
    +
  • Your vote receipt email and the ballot verification page may show an election definition digest for v2 elections.
  • +
  • You can compare that digest with the value published in the public audit log.
  • +
  • verify-ballot-chain.py checks the local manifest-to-anchor-to-ballot-chain relationship, while verify-audit-log.py is the step that independently confirms the Rekor-backed election-start evidence.
  • +
+

Independent verification tools

For maximum transparency, you can download and run these verification scripts locally: @@ -105,11 +114,11 @@

Independent verification tools

  • verify-ballot-chain.py - — Verify the complete ballot chain is unbroken and includes your ballot + — Verify the complete ballot chain is unbroken and, for v2 elections, that it matches the published election-definition digest and chain anchor
  • - verify-audit-log.py - — Verify Rekor transparency-log attestations in the public audit log (offline digest check; optional online Rekor query) + verify-audit-log.py + — Verify Rekor transparency-log attestations in the public audit log and confirm whether v2 election-definition verification is `valid`, `invalid`, or `untrusted_local_only`
  • diff --git a/astra_app/core/tests/test_admin_election_lifecycle.py b/astra_app/core/tests/test_admin_election_lifecycle.py index c28be57e..c6121546 100644 --- a/astra_app/core/tests/test_admin_election_lifecycle.py +++ b/astra_app/core/tests/test_admin_election_lifecycle.py @@ -2,13 +2,25 @@ import datetime from unittest.mock import patch +from django.contrib import admin from django.test import TestCase +from django.test.client import RequestFactory from django.urls import NoReverseMatch, reverse from django.utils import timezone from core import elections_services from core.freeipa.user import FreeIPAUser -from core.models import AuditLogEntry, Ballot, Candidate, Election, Membership, MembershipType, VotingCredential +from core.models import ( + AuditLogEntry, + Ballot, + Candidate, + Election, + ExclusionGroup, + ExclusionGroupCandidate, + Membership, + MembershipType, + VotingCredential, +) from core.tests.ballot_chain import compute_chain_hash from core.tests.utils_test_data import ensure_core_categories, ensure_email_templates from core.tokens import election_genesis_chain_hash @@ -25,6 +37,38 @@ def _login_as_freeipa_admin(self, username: str = "alice") -> None: session["_freeipa_username"] = username session.save() + def test_election_admin_add_does_not_expose_chain_version_field(self) -> None: + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + response = self.client.get(reverse("admin:core_election_add")) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="chain_version"') + + def test_election_admin_draft_change_does_not_expose_chain_version_field(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Draft admin chain version hidden", + description="", + url="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + quorum=10, + status=Election.Status.draft, + ) + + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + response = self.client.get(reverse("admin:core_election_change", args=[election.id])) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="chain_version"') + def test_admin_add_allows_zero_candidate_draft_creation(self) -> None: self._login_as_freeipa_admin("alice") admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) @@ -120,6 +164,7 @@ def test_admin_action_close_election_closes_and_anonymizes(self) -> None: end_datetime=now + datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.open, + chain_version=1, ) VotingCredential.objects.create( election=election, @@ -161,6 +206,7 @@ def test_admin_close_election_action_records_actor(self) -> None: end_datetime=now + datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.open, + chain_version=1, ) VotingCredential.objects.create( election=election, @@ -200,6 +246,7 @@ def test_admin_action_tally_election_tallies_and_logs_public_rounds(self) -> Non end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="nominator") c2 = Candidate.objects.create(election=election, freeipa_username="bob", nominated_by="nominator") @@ -343,6 +390,179 @@ def test_election_admin_status_is_readonly(self) -> None: self.assertEqual(response.status_code, 200) self.assertNotContains(response, 'name="status"') + def test_started_v2_election_admin_manifest_fields_are_readonly(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Readonly manifest election", + description="", + url="https://example.com/election", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.open, + chain_version=2, + ) + + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + url = reverse("admin:core_election_change", args=[election.id]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="name"') + self.assertContains(response, 'name="description"') + self.assertContains(response, 'name="url"') + self.assertNotContains(response, 'name="start_datetime"') + self.assertNotContains(response, 'name="number_of_seats"') + self.assertNotContains(response, 'name="quorum"') + self.assertNotContains(response, 'name="eligible_group_cn"') + + def test_started_v2_election_admin_readonly_fields_follow_model_ssot(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Readonly field ssot election", + description="", + url="https://example.com/election", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.open, + chain_version=2, + ) + + request = RequestFactory().get("/") + model_admin = admin.site._registry[Election] + + self.assertEqual( + election.v2_started_readonly_field_names(), + ( + *election.v2_immutable_field_names(), + "chain_version", + "config_manifest_version", + "config_manifest", + "config_manifest_sha256", + "chain_anchor_hash", + ), + ) + self.assertEqual( + model_admin.get_readonly_fields(request, election), + ("status", *election.v2_started_readonly_field_names()), + ) + + def test_started_v2_election_admin_candidate_inline_keeps_profile_fields_editable(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Readonly candidate inline election", + description="", + url="https://example.com/election", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + url="https://example.com/candidates/alice", + description="candidate", + ) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + url = reverse("admin:core_election_change", args=[election.id]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="candidates-0-freeipa_username"') + self.assertNotContains(response, 'name="candidates-0-nominated_by"') + self.assertContains(response, 'name="candidates-0-url"') + self.assertContains(response, 'name="candidates-0-description"') + + def test_started_v2_candidate_admin_keeps_profile_fields_editable(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Readonly candidate detail election", + description="", + url="https://example.com/election", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + url="https://example.com/candidates/alice", + description="candidate", + ) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + url = reverse("admin:core_candidate_change", args=[candidate.id]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="freeipa_username"') + self.assertNotContains(response, 'name="nominated_by"') + self.assertContains(response, 'name="url"') + self.assertContains(response, 'name="description"') + + def test_started_v2_exclusion_group_membership_inline_is_not_editable(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Readonly group membership election", + description="", + url="https://example.com/election", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + group = ExclusionGroup.objects.create(election=election, name="Employees", max_elected=1) + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + self._login_as_freeipa_admin("alice") + admin_user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": ["admins"]}) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=admin_user): + url = reverse("admin:core_exclusiongroup_change", args=[group.id]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'name="group_candidates-0-candidate"') + def test_ballots_and_voting_credentials_are_not_registered_in_admin(self) -> None: with self.assertRaises(NoReverseMatch): reverse("admin:core_votingcredential_changelist") diff --git a/astra_app/core/tests/test_ballot_verification_page.py b/astra_app/core/tests/test_ballot_verification_page.py index 06c287c4..21e25de2 100644 --- a/astra_app/core/tests/test_ballot_verification_page.py +++ b/astra_app/core/tests/test_ballot_verification_page.py @@ -7,9 +7,10 @@ from django.urls import reverse from django.utils import timezone +from core.election_chain import build_config_manifest, config_manifest_sha256 from core.models import Ballot, Candidate, Election from core.tests.ballot_chain import compute_chain_hash -from core.tokens import election_genesis_chain_hash +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash class BallotVerificationPageTests(TestCase): @@ -56,6 +57,7 @@ def test_verify_page_renders_and_rejects_invalid_receipt_format(self) -> None: self.assertEqual(resp.status_code, 200) self.assertContains(resp, "data-ballot-verify-root") self.assertContains(resp, reverse("api-ballot-verify")) + self.assertContains(resp, reverse("verify-audit-log-download")) self.assertContains(resp, 'data-ballot-verify-election-detail-url-template="/elections/__election_id__/"') self.assertContains(resp, 'data-ballot-verify-audit-log-url-template="/elections/__election_id__/audit/"') @@ -82,6 +84,7 @@ def test_verify_page_open_election_never_says_counted(self) -> None: start_datetime=now - datetime.timedelta(days=1), end_datetime=now + datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.open, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="n") @@ -114,6 +117,7 @@ def test_verify_page_closed_election_indicates_locked_and_upcoming_tally(self) - start_datetime=now - datetime.timedelta(days=10), end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.closed, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="n") @@ -145,6 +149,7 @@ def test_verify_page_tallied_election_links_to_public_exports(self) -> None: start_datetime=now - datetime.timedelta(days=10), end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.tallied, tally_result={"quota": "1", "elected": [], "eliminated": [], "forced_excluded": [], "rounds": []}, ) @@ -169,6 +174,53 @@ def test_verify_page_tallied_election_links_to_public_exports(self) -> None: self.assertNotIn("audit_log_url", payload) self.assertFalse(payload["is_superseded"]) + def test_verify_page_returns_v2_integrity_metadata(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Tallied v2 election", + description="", + start_datetime=now - datetime.timedelta(days=10), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.draft, + chain_version=2, + tally_result={"quota": "1", "elected": [], "eliminated": [], "forced_excluded": [], "rounds": []}, + ) + candidate = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="n") + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.tallied, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + + created_at = timezone.make_aware(datetime.datetime(2026, 1, 2, 12, 34, 56)) + ballot = self._create_ballot( + election=election, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + previous_chain_hash=anchor_hash, + created_at=created_at, + ) + + resp = self.client.get(reverse("api-ballot-verify"), data={"receipt": ballot.ballot_hash}, HTTP_ACCEPT="application/json") + self.assertEqual(resp.status_code, 200) + payload = resp.json() + self.assertEqual(payload["chain_version"], 2) + self.assertEqual(payload["config_manifest_sha256"], manifest_digest) + self.assertEqual(payload["genesis_hash"], anchor_hash) + self.assertNotIn("chain_anchor_hash", payload) + self.assertTrue(payload["public_audit_url"].endswith(reverse("election-public-audit", args=[election.id]))) + def test_verify_page_superseded_ballot_does_not_reveal_replacement_receipt(self) -> None: now = timezone.now() election = Election.objects.create( @@ -177,6 +229,7 @@ def test_verify_page_superseded_ballot_does_not_reveal_replacement_receipt(self) start_datetime=now - datetime.timedelta(days=10), end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.tallied, tally_result={"quota": "1", "elected": [], "eliminated": [], "forced_excluded": [], "rounds": []}, ) @@ -232,6 +285,7 @@ def test_ballot_verify_does_not_expose_credential_to_unauthenticated(self) -> No start_datetime=now - datetime.timedelta(days=1), end_datetime=now + datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.open, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="n") @@ -264,6 +318,7 @@ def test_ballot_verify_does_not_expose_credential_to_authenticated_non_owner(sel start_datetime=now - datetime.timedelta(days=1), end_datetime=now + datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.open, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="n") diff --git a/astra_app/core/tests/test_election_algorithm_docs_page.py b/astra_app/core/tests/test_election_algorithm_docs_page.py index 4ba8bccb..c9510a17 100644 --- a/astra_app/core/tests/test_election_algorithm_docs_page.py +++ b/astra_app/core/tests/test_election_algorithm_docs_page.py @@ -1,13 +1,35 @@ +import hashlib from unittest.mock import patch -from django.test import TestCase +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from django.test import TestCase, override_settings from django.urls import reverse from core.freeipa.user import FreeIPAUser class ElectionAlgorithmDocsPageTests(TestCase): + def _generate_signing_material(self) -> tuple[str, str, str]: + private_key = ec.generate_private_key(ec.SECP256R1()) + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + public_key = private_key.public_key() + public_key_pem = public_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + public_key_der = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_key_fingerprint = hashlib.sha256(public_key_der).hexdigest() + return private_key_pem, public_key_pem, public_key_fingerprint + def _login_as_freeipa(self, username: str = "alice") -> None: session = self.client.session session["_freeipa_username"] = username @@ -22,3 +44,38 @@ def test_algorithm_docs_page_renders(self) -> None: self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Meek STV (High-Precision Variant)") self.assertContains(resp, "80-digit precision") + + def test_algorithm_docs_page_links_to_generated_audit_verifier_download(self) -> None: + self._login_as_freeipa("alice") + + user = FreeIPAUser("alice", {"uid": ["alice"], "memberof_group": []}) + with patch("core.freeipa.user.FreeIPAUser.get", return_value=user): + resp = self.client.get(reverse("election-algorithm")) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, reverse("verify-audit-log-download")) + + @override_settings(ELECTION_REKOR_SIGNING_KEY="") + def test_downloaded_audit_verifier_uses_configured_signing_key(self) -> None: + private_key_pem, expected_public_key_pem, expected_fingerprint = self._generate_signing_material() + + with self.settings(ELECTION_REKOR_SIGNING_KEY=private_key_pem): + resp = self.client.get(reverse("verify-audit-log-download")) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp["Content-Disposition"], 'attachment; filename="verify-audit-log.py"') + self.assertIn(expected_public_key_pem.strip(), resp.content.decode("utf-8")) + self.assertIn( + f'trusted_public_key_sha256: str = "{expected_fingerprint}"', + resp.content.decode("utf-8"), + ) + + @override_settings(ELECTION_REKOR_SIGNING_KEY="-----BEGIN PRIVATE KEY-----invalid") + def test_downloaded_audit_verifier_fails_closed_when_signing_key_is_invalid(self) -> None: + resp = self.client.get(reverse("verify-audit-log-download")) + + self.assertEqual(resp.status_code, 503) + self.assertEqual(resp["Content-Type"], "text/plain; charset=utf-8") + self.assertNotIn("Content-Disposition", resp) + self.assertContains(resp, "Audit verifier download is temporarily unavailable.", status_code=503) + self.assertNotContains(resp, "trusted_public_key_sha256: str = \"99f3b7b90a6d81ac36e8aaf8066d3da0d7ccd49dab06995ad6eeb83384a0dd12\"", status_code=503) diff --git a/astra_app/core/tests/test_election_chain_v2.py b/astra_app/core/tests/test_election_chain_v2.py new file mode 100644 index 00000000..75da9ad4 --- /dev/null +++ b/astra_app/core/tests/test_election_chain_v2.py @@ -0,0 +1,432 @@ +import datetime +import hashlib +import json +import queue +import threading +import uuid + +from django.core.exceptions import ValidationError +from django.db import close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone + +from core.election_chain import ( + build_config_manifest, + canonical_config_manifest_bytes, + config_manifest_sha256, + load_locked_manifest_source_rows, +) +from core.elections_services import build_public_ballots_export, submit_ballot +from core.models import ( + Ballot, + Candidate, + Election, + ExclusionGroup, + ExclusionGroupCandidate, + VotingCredential, +) +from core.tokens import election_chain_anchor_hash +from core.views_elections import edit as election_edit_view + + +class ElectionChainV2Tests(TestCase): + def test_build_config_manifest_matches_pinned_contract(self) -> None: + start_datetime = datetime.datetime(2026, 5, 1, 12, 30, tzinfo=datetime.UTC) + election = Election.objects.create( + name="Chain v2 election", + description="Immutable config test", + url="https://example.com/elections/chain-v2", + start_datetime=start_datetime, + end_datetime=start_datetime + datetime.timedelta(days=1), + number_of_seats=2, + quorum=25, + eligible_group_cn="council", + status=Election.Status.draft, + chain_version=2, + ) + candidate_b = Candidate.objects.create( + election=election, + freeipa_username="bravo", + nominated_by="nominator-b", + description="Bravo candidate", + url="https://example.com/candidates/bravo", + tiebreak_uuid=uuid.UUID("00000000-0000-0000-0000-0000000000b2"), + ) + candidate_a = Candidate.objects.create( + election=election, + freeipa_username="alpha", + nominated_by="nominator-a", + description="Alpha candidate", + url="https://example.com/candidates/alpha", + tiebreak_uuid=uuid.UUID("00000000-0000-0000-0000-0000000000a1"), + ) + group = ExclusionGroup.objects.create( + election=election, + name="Employees", + max_elected=1, + public_id=uuid.UUID("10000000-0000-0000-0000-000000000001"), + ) + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate_b) + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate_a) + + manifest = build_config_manifest(election=election) + self.maxDiff = None + expected_manifest = { + "version": 1, + "election": { + "id": election.id, + "name": "Chain v2 election", + "start_datetime": "2026-05-01T12:30:00Z", + "number_of_seats": 2, + "quorum": 25, + "eligible_group_cn": "council", + }, + "tally_rule": { + "algorithm": "Meek STV (High-Precision Variant)", + "algorithm_version": "1.0", + "spec_identity": "docs/runbooks/meek-stv-elections.md", + "epsilon": "1E-28", + "max_iterations": 200, + }, + "candidates": [ + { + "id": candidate_b.id, + "freeipa_username": "bravo", + "nominated_by": "nominator-b", + "tiebreak_uuid": "00000000-0000-0000-0000-0000000000b2", + }, + { + "id": candidate_a.id, + "freeipa_username": "alpha", + "nominated_by": "nominator-a", + "tiebreak_uuid": "00000000-0000-0000-0000-0000000000a1", + }, + ], + "exclusion_groups": [ + { + "public_id": "10000000-0000-0000-0000-000000000001", + "name": "Employees", + "max_elected": 1, + "candidate_ids": [candidate_b.id, candidate_a.id], + } + ], + } + self.assertEqual(manifest, expected_manifest) + + expected_bytes = json.dumps(expected_manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + expected_digest = hashlib.sha256(expected_bytes).hexdigest() + + self.assertEqual(canonical_config_manifest_bytes(manifest), expected_bytes) + self.assertEqual(config_manifest_sha256(manifest), expected_digest) + self.assertEqual( + election_chain_anchor_hash(election_id=election.id, config_manifest_sha256=expected_digest), + hashlib.sha256( + ( + f"election-v2:{election.id}:{expected_digest}. " + "alex estuvo aquí, dejándose el alma." + ).encode() + ).hexdigest(), + ) + + def test_build_public_ballots_export_uses_v2_root_and_stored_manifest_rankings(self) -> None: + start_datetime = timezone.now() - datetime.timedelta(days=2) + election = Election.objects.create( + name="Artifact v2 election", + description="", + url="https://example.com/elections/artifact-v2", + start_datetime=start_datetime, + end_datetime=start_datetime + datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + tiebreak_uuid=uuid.UUID("00000000-0000-0000-0000-000000000101"), + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.closed, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + ballot_hash = Ballot.compute_hash( + election_id=election.id, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + nonce="0" * 32, + ) + chain_hash = hashlib.sha256(f"{anchor_hash}:{ballot_hash}".encode()).hexdigest() + Ballot.objects.create( + election=election, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + ballot_hash=ballot_hash, + previous_chain_hash=anchor_hash, + chain_hash=chain_hash, + is_counted=True, + ) + Candidate.objects.filter(pk=candidate.pk).update(freeipa_username="mallory") + + payload = build_public_ballots_export(election=election) + + self.assertEqual(payload["chain_version"], 2) + self.assertEqual(payload["chain_root_kind"], "config_anchor_v2") + self.assertNotIn("chain_root_hash", payload) + self.assertNotIn("chain_anchor_hash", payload) + self.assertEqual(payload["genesis_hash"], anchor_hash) + self.assertEqual(payload["config_manifest_version"], 1) + self.assertEqual(payload["config_manifest_sha256"], manifest_digest) + self.assertEqual(payload["chain_head"], chain_hash) + self.assertEqual(payload["ballots"][0]["ranking"], ["alice"]) + + def test_submit_ballot_uses_chain_anchor_for_first_v2_ballot(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Open v2 election", + description="", + url="", + start_datetime=now - datetime.timedelta(hours=1), + end_datetime=now + datetime.timedelta(hours=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.open, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + VotingCredential.objects.create( + election=election, + public_id="cred-1", + freeipa_username="alice", + weight=1, + ) + + receipt = submit_ballot(election=election, credential_public_id="cred-1", ranking=[candidate.id]) + + self.assertEqual(receipt.ballot.previous_chain_hash, anchor_hash) + + def test_build_public_ballots_export_uses_anchor_as_chain_head_when_v2_has_no_ballots(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Empty v2 election", + description="", + url="", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now, + number_of_seats=1, + quorum=0, + eligible_group_cn="", + status=Election.Status.draft, + chain_version=2, + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.closed, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + + payload = build_public_ballots_export(election=election) + + self.assertEqual(payload["chain_head"], anchor_hash) + self.assertNotIn("chain_root_hash", payload) + self.assertNotIn("chain_anchor_hash", payload) + self.assertEqual(payload["genesis_hash"], anchor_hash) + + def test_load_locked_manifest_source_rows_locks_candidate_and_exclusion_tables(self) -> None: + start_datetime = timezone.now() - datetime.timedelta(days=2) + election = Election.objects.create( + name="Locked manifest source election", + description="", + url="", + start_datetime=start_datetime, + end_datetime=start_datetime + datetime.timedelta(days=1), + number_of_seats=1, + quorum=1, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + group = ExclusionGroup.objects.create(election=election, name="Employees", max_elected=1) + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + + with transaction.atomic(): + with CaptureQueriesContext(connection) as ctx: + candidate_rows, groups, memberships = load_locked_manifest_source_rows(election=election) + + self.assertEqual(len(candidate_rows), 1) + self.assertEqual(len(groups), 1) + self.assertEqual(len(memberships), 1) + + sql_statements = [query["sql"] for query in ctx.captured_queries if "FOR UPDATE" in query["sql"]] + self.assertTrue(any("core_candidate" in sql for sql in sql_statements), sql_statements) + self.assertTrue(any("core_exclusiongroup" in sql for sql in sql_statements), sql_statements) + self.assertTrue(any("core_exclusiongroupcandidate" in sql for sql in sql_statements), sql_statements) + + def test_save_candidates_and_groups_rejects_stale_started_v2_membership_reset(self) -> None: + election = Election.objects.create( + name="Stale draft election", + description="", + url="https://example.com/elections/stale-draft", + start_datetime=timezone.now() + datetime.timedelta(days=1), + end_datetime=timezone.now() + datetime.timedelta(days=2), + number_of_seats=1, + quorum=1, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + group = ExclusionGroup.objects.create( + election=election, + name="Employees", + max_elected=1, + ) + membership = ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + + stale_election = Election.objects.get(pk=election.pk) + stale_group = ExclusionGroup.objects.get(pk=group.pk) + + Election.objects.filter(pk=election.pk).update(status=Election.Status.open) + + class _FakeForm: + def __init__(self, *, instance, cleaned_data: dict[str, object]) -> None: + self.instance = instance + self.cleaned_data = cleaned_data + + def save(self, *, commit: bool = False): + del commit + return self.instance + + class _FakeFormSet: + def __init__(self, forms: list[object]) -> None: + self.forms = forms + + with self.assertRaises(ValidationError): + election_edit_view._save_candidates_and_groups( + stale_election, + _FakeFormSet([]), + _FakeFormSet( + [ + _FakeForm( + instance=stale_group, + cleaned_data={ + "DELETE": False, + "name": stale_group.name, + "candidate_usernames": [], + }, + ) + ] + ), + ) + + self.assertTrue(ExclusionGroupCandidate.objects.filter(pk=membership.pk).exists()) + + +class ElectionChainV2TransactionTests(TransactionTestCase): + def test_candidate_insert_waits_for_start_lock_and_rechecks_open_status(self) -> None: + if not connection.features.has_select_for_update: + self.skipTest("database does not support select_for_update") + + election = Election.objects.create( + name="Concurrent start election", + description="", + url="https://example.com/elections/concurrent-start", + start_datetime=timezone.now() + datetime.timedelta(days=1), + end_datetime=timezone.now() + datetime.timedelta(days=2), + number_of_seats=1, + quorum=1, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + + started = threading.Event() + finished = threading.Event() + errors: queue.Queue[BaseException] = queue.Queue() + + def run() -> None: + close_old_connections() + started.set() + try: + Candidate.objects.create( + election_id=election.id, + freeipa_username="alice", + nominated_by="nominator", + ) + except BaseException as exc: # pragma: no cover - surfaced by assertions below + errors.put(exc) + finally: + finished.set() + close_old_connections() + + with transaction.atomic(): + locked = Election.objects.select_for_update().get(pk=election.pk) + + worker = threading.Thread(target=run) + worker.start() + self.assertTrue(started.wait(timeout=1), "worker thread did not start") + self.assertFalse( + finished.wait(timeout=0.2), + "candidate insert completed before the start-transition election lock was released", + ) + + locked.status = Election.Status.open + locked.save(update_fields=["status", "updated_at"]) + + worker.join(timeout=5) + self.assertFalse(worker.is_alive(), "worker thread did not finish") + self.assertFalse(Candidate.objects.filter(election=election).exists()) + self.assertFalse(errors.empty(), "candidate insert unexpectedly succeeded after election opened") + self.assertIsInstance(errors.get(), ValidationError) \ No newline at end of file diff --git a/astra_app/core/tests/test_election_edit_lifecycle.py b/astra_app/core/tests/test_election_edit_lifecycle.py index b8105106..bfd33b60 100644 --- a/astra_app/core/tests/test_election_edit_lifecycle.py +++ b/astra_app/core/tests/test_election_edit_lifecycle.py @@ -384,6 +384,172 @@ def test_start_election_audit_entry_includes_actor(self) -> None: self.assertIsInstance(audit.payload, dict) self.assertEqual(audit.payload.get("actor"), "admin") + @override_settings( + ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS=1, + ELECTION_REKOR_ENDPOINT="", + ELECTION_REKOR_SIGNING_KEY="", + ) + def test_start_legacy_election_preserves_legacy_chain_version(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Legacy draft election", + description="", + url="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + status=Election.Status.draft, + voting_email_subject="Hello {{ username }}", + voting_email_html="

    Hi {{ username }}

    ", + voting_email_text="Hi {{ username }}", + chain_version=1, + ) + Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="nominator") + + mt = MembershipType.objects.create( + code="voter", + name="Voter", + description="", + category_id="individual", + sort_order=1, + enabled=True, + votes=1, + ) + membership = Membership.objects.create(target_username="voter1", membership_type=mt, expires_at=None) + Membership.objects.filter(pk=membership.pk).update(created_at=now - datetime.timedelta(days=200)) + + candidate_membership = Membership.objects.create(target_username="alice", membership_type=mt, expires_at=None) + Membership.objects.filter(pk=candidate_membership.pk).update(created_at=now - datetime.timedelta(days=200)) + nominator_membership = Membership.objects.create( + target_username="nominator", + membership_type=mt, + expires_at=None, + ) + Membership.objects.filter(pk=nominator_membership.pk).update(created_at=now - datetime.timedelta(days=200)) + + self._login_as_freeipa_user("admin") + self._grant_manage_elections("admin") + + admin_user = FreeIPAUser("admin", {"uid": ["admin"], "memberof_group": []}) + voter_user = FreeIPAUser( + "voter1", + {"uid": ["voter1"], "memberof_group": [], "mail": ["voter1@example.com"]}, + ) + + def get_user(username: str, **_: object): + if username == "admin": + return admin_user + if username == "voter1": + return voter_user + return None + + start_str = election.start_datetime.strftime("%Y-%m-%dT%H:%M") + end_str = election.end_datetime.strftime("%Y-%m-%dT%H:%M") + + with ( + patch("core.freeipa.user.FreeIPAUser.get", side_effect=get_user), + patch( + "core.views_elections.edit.elections_services.send_voting_credential_email", + autospec=True, + ), + ): + response = self.client.post( + reverse("election-edit", args=[election.id]), + data={ + "action": "start_election", + "name": election.name, + "description": election.description, + "url": election.url, + "start_datetime": start_str, + "end_datetime": end_str, + "number_of_seats": str(election.number_of_seats), + "quorum": str(election.quorum), + "email_template_id": "", + "subject": election.voting_email_subject, + "html_content": election.voting_email_html, + "text_content": election.voting_email_text, + }, + follow=False, + ) + + self.assertEqual(response.status_code, 302) + election.refresh_from_db() + self.assertEqual(election.status, Election.Status.open) + self.assertEqual(election.chain_version, 1) + + @override_settings( + ELECTION_REKOR_ENDPOINT="", + ELECTION_REKOR_SIGNING_KEY="", + ) + def test_start_v2_election_fails_closed_without_attestation_configuration(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Draft v2 election", + description="", + url="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + status=Election.Status.draft, + voting_email_subject="Hello {{ username }}", + voting_email_html="

    Hi {{ username }}

    ", + voting_email_text="Hi {{ username }}", + chain_version=2, + ) + Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="nominator") + + self._login_as_freeipa_user("admin") + self._grant_manage_elections("admin") + + validation = CandidateValidationResult( + disqualified_candidates=[], + disqualified_nominators=[], + ineligible_candidates=[], + ineligible_nominators=[], + eligible_candidates=["alice"], + eligible_nominators=["nominator"], + ) + eligible_voter = EligibleVoter(username="voter1", weight=1) + start_str = (now + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M") + end_str = (now + datetime.timedelta(days=2)).strftime("%Y-%m-%dT%H:%M") + + with ( + patch( + "core.views_elections.edit.elections_eligibility.eligible_voters_from_memberships", + return_value=[eligible_voter], + ), + patch( + "core.views_elections.edit.elections_eligibility.validate_candidates_for_election", + return_value=validation, + ), + patch("core.views_elections.edit._issue_and_email_credentials") as issue_mock, + ): + response = self.client.post( + reverse("election-edit", args=[election.id]), + data={ + "action": "start_election", + "name": election.name, + "description": election.description, + "url": election.url, + "start_datetime": start_str, + "end_datetime": end_str, + "number_of_seats": str(election.number_of_seats), + "quorum": str(election.quorum), + "email_template_id": "", + "subject": election.voting_email_subject, + "html_content": election.voting_email_html, + "text_content": election.voting_email_text, + }, + follow=False, + ) + + election.refresh_from_db() + self.assertEqual(response.status_code, 200) + self.assertEqual(election.status, Election.Status.draft) + self.assertFalse(AuditLogEntry.objects.filter(election=election, event_type="election_started").exists()) + issue_mock.assert_not_called() + self.assertContains(response, "start did not complete", status_code=200) + @override_settings(ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS=1) def test_start_election_blocks_self_nominating_candidate(self) -> None: now = timezone.now() diff --git a/astra_app/core/tests/test_election_vote_receipt_template_copy_migration.py b/astra_app/core/tests/test_election_vote_receipt_template_copy_migration.py index 2350cc50..6551494e 100644 --- a/astra_app/core/tests/test_election_vote_receipt_template_copy_migration.py +++ b/astra_app/core/tests/test_election_vote_receipt_template_copy_migration.py @@ -58,4 +58,30 @@ def test_migration_updates_vote_receipt_template_to_canonical_verification_terms self.assertNotIn( "a ballot corresponding to your ballot receipt code exists in the system", template.content, - ) \ No newline at end of file + ) + + def test_followup_migration_adds_election_definition_digest_guidance(self) -> None: + from post_office.models import EmailTemplate + + EmailTemplate.objects.update_or_create( + name=settings.ELECTION_VOTE_RECEIPT_EMAIL_TEMPLATE_NAME, + defaults={ + "subject": "Old subject", + "content": "Receipt code {{ ballot_hash }} and nonce {{ nonce }}", + "html_content": "

    Receipt code {{ ballot_hash }}

    ", + }, + ) + + migration = import_module( + "core.migrations.0098_update_election_vote_receipt_template_manifest_digest_copy" + ) + migration.update_election_vote_receipt_template_manifest_digest_copy(apps, None) + + template = EmailTemplate.objects.get(name=settings.ELECTION_VOTE_RECEIPT_EMAIL_TEMPLATE_NAME) + + self.assertIn("Election definition digest:", template.content) + self.assertIn("{{ config_manifest_sha256 }}", template.content) + self.assertIn("Compare this digest with the public audit log", template.content) + self.assertIn("Election definition digest:", template.html_content) + self.assertIn("{{ config_manifest_sha256 }}", template.html_content) + self.assertIn("Compare this digest with the public audit log", template.html_content) \ No newline at end of file diff --git a/astra_app/core/tests/test_elections_api.py b/astra_app/core/tests/test_elections_api.py index 18a76e6b..9e66ddc2 100644 --- a/astra_app/core/tests/test_elections_api.py +++ b/astra_app/core/tests/test_elections_api.py @@ -838,13 +838,16 @@ def _get_user(username: str): def test_ballot_verify_api_returns_tallied_verification_details(self) -> None: now = timezone.now() + published_at = timezone.make_aware(datetime.datetime(2026, 4, 11, 10, 15, 0)) election = Election.objects.create( name="Tallied verification election", description="", start_datetime=now - datetime.timedelta(days=10), end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, + chain_version=1, status=Election.Status.tallied, + artifacts_generated_at=published_at, tally_result={"quota": "1", "elected": [], "eliminated": [], "forced_excluded": [], "rounds": []}, ) candidate = Candidate.objects.create( @@ -892,7 +895,10 @@ def test_ballot_verify_api_returns_tallied_verification_details(self) -> None: self.assertFalse(payload["is_superseded"]) self.assertTrue(payload["is_final_ballot"]) self.assertTrue(payload["public_ballots_url"].endswith(reverse("election-public-ballots", args=[election.id]))) + self.assertTrue(payload["public_audit_url"].endswith(reverse("election-public-audit", args=[election.id]))) + self.assertEqual(payload["publication_bundle"], {"published_at": "2026-04-11T10:15:00Z"}) self.assertNotIn("audit_log_url", payload) + self.assertNotIn("public_bundle_url", payload) self.assertIn(f"election_id = {election.id}", payload["verification_snippet"]) def test_election_vote_submit_api_alias_uses_existing_submission_contract(self) -> None: diff --git a/astra_app/core/tests/test_elections_artifacts.py b/astra_app/core/tests/test_elections_artifacts.py index 2438d46b..a880eee7 100644 --- a/astra_app/core/tests/test_elections_artifacts.py +++ b/astra_app/core/tests/test_elections_artifacts.py @@ -1,18 +1,91 @@ import datetime import json +from unittest.mock import patch -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from core import elections_services +from core.election_chain import build_config_manifest, config_manifest_sha256 from core.models import AuditLogEntry, Ballot, Candidate, Election from core.tests.ballot_chain import compute_chain_hash -from core.tokens import election_genesis_chain_hash +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash class ElectionArtifactGenerationTests(TestCase): + def _create_coherent_v2_election( + self, + *, + status: str = Election.Status.tallied, + include_ballot: bool = False, + ) -> tuple[Election, Candidate, str, str]: + now = timezone.now() + election = Election.objects.create( + name="Artifact v2 election", + description="", + url="https://example.com/elections/artifact-v2", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=status, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + AuditLogEntry.objects.create( + election=election, + event_type="election_started", + payload={ + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + }, + is_public=True, + ) + if include_ballot: + ballot_hash = Ballot.compute_hash( + election_id=election.id, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + nonce="0" * 32, + ) + chain_hash = compute_chain_hash(previous_chain_hash=anchor_hash, ballot_hash=ballot_hash) + Ballot.objects.create( + election=election, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + ballot_hash=ballot_hash, + previous_chain_hash=anchor_hash, + chain_hash=chain_hash, + is_counted=True, + ) + return election, candidate, manifest_digest, anchor_hash + def test_build_public_ballots_export_uses_candidate_usernames_in_rankings(self) -> None: now = timezone.now() election = Election.objects.create( @@ -22,6 +95,7 @@ def test_build_public_ballots_export_uses_candidate_usernames_in_rankings(self) end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create( election=election, @@ -60,6 +134,7 @@ def test_tally_generates_public_ballots_and_audit_artifacts(self) -> None: end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create( election=election, @@ -95,6 +170,66 @@ def test_tally_generates_public_ballots_and_audit_artifacts(self) -> None: self.assertIn(f"elections/{election.id}/", election.public_ballots_file.name) self.assertIn(f"elections/{election.id}/", election.public_audit_file.name) + def test_tally_persists_public_audit_artifact_with_public_tally_events(self) -> None: + election, _candidate, _manifest_digest, _anchor_hash = self._create_coherent_v2_election( + status=Election.Status.closed, + include_ballot=True, + ) + + elections_services.tally_election(election=election) + election.refresh_from_db() + + with election.public_audit_file.open("rb") as fh: + payload = json.loads(fh.read().decode("utf-8")) + + event_types = [event["event_type"] for event in payload["audit_log"]] + self.assertEqual(event_types[0], "election_started") + self.assertIn("tally_round", event_types) + self.assertIn("tally_completed", event_types) + + @override_settings( + ELECTION_REKOR_ENDPOINT="https://rekor.example", + ELECTION_REKOR_SIGNING_KEY="-----BEGIN PRIVATE KEY-----fake", + ) + def test_tally_persists_public_audit_artifact_with_tally_completed_timestamping(self) -> None: + election, _candidate, _manifest_digest, _anchor_hash = self._create_coherent_v2_election( + status=Election.Status.closed, + include_ballot=True, + ) + + def _fake_attest_entry(entry: AuditLogEntry) -> None: + entry.rekor_log_id = "uuid-tally-completed" + entry.rekor_endpoint = "https://rekor.example" + entry.rekor_log_index = 7 + entry.rekor_message_digest_hex = "a" * 64 + entry.rekor_canonical_message_version = 1 + entry.rekor_integrated_time = timezone.now() + entry.save( + update_fields=[ + "rekor_log_id", + "rekor_endpoint", + "rekor_log_index", + "rekor_message_digest_hex", + "rekor_canonical_message_version", + "rekor_integrated_time", + ] + ) + + with patch("core.elections_timestamping._attest_entry", side_effect=_fake_attest_entry): + with self.captureOnCommitCallbacks(execute=True): + elections_services.tally_election(election=election) + + election.refresh_from_db() + + with election.public_audit_file.open("rb") as fh: + payload = json.loads(fh.read().decode("utf-8")) + + tally_completed_event = next( + event for event in payload["audit_log"] if event["event_type"] == "tally_completed" + ) + self.assertEqual(tally_completed_event["timestamping"]["rekor_log_id"], "uuid-tally-completed") + self.assertEqual(tally_completed_event["timestamping"]["rekor_log_index"], 7) + def test_public_export_endpoints_redirect_to_stored_artifacts_when_tallied(self) -> None: now = timezone.now() election = Election.objects.create( @@ -104,6 +239,7 @@ def test_public_export_endpoints_redirect_to_stored_artifacts_when_tallied(self) end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create( election=election, @@ -150,6 +286,7 @@ def test_persisted_public_audit_artifact_hides_sensitive_close_counts(self) -> N end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.tallied, + chain_version=1, ) AuditLogEntry.objects.create( @@ -183,6 +320,7 @@ def test_persisted_public_audit_artifact_hides_start_operational_fields(self) -> end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.tallied, + chain_version=1, ) AuditLogEntry.objects.create( @@ -239,6 +377,7 @@ def test_persisted_public_audit_artifact_hides_rekor_failure_error_type(self) -> end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.tallied, + chain_version=1, ) AuditLogEntry.objects.create( @@ -257,3 +396,127 @@ def test_persisted_public_audit_artifact_hides_rekor_failure_error_type(self) -> self.assertEqual(payload["audit_log"][0]["event_type"], "rekor_attestation_failed") self.assertEqual(payload["audit_log"][0]["payload"], {}) self.assertNotIn("ConnectionError", json.dumps(payload)) + + def test_persist_public_election_artifacts_sets_shared_publication_bundle_metadata(self) -> None: + election, _candidate, _manifest_digest, _anchor_hash = self._create_coherent_v2_election( + include_ballot=True, + ) + published_at = timezone.make_aware(datetime.datetime(2026, 4, 11, 10, 15, 0)) + + with patch("django.utils.timezone.now", return_value=published_at): + elections_services.persist_public_election_artifacts(election=election) + + election.refresh_from_db() + self.assertEqual(election.artifacts_generated_at, published_at) + + with election.public_ballots_file.open("rb") as fh: + ballots_payload = json.loads(fh.read().decode("utf-8")) + with election.public_audit_file.open("rb") as fh: + audit_payload = json.loads(fh.read().decode("utf-8")) + + expected_bundle = {"published_at": "2026-04-11T10:15:00Z"} + self.assertEqual(ballots_payload["publication_bundle"], expected_bundle) + self.assertEqual(audit_payload["publication_bundle"], expected_bundle) + + def test_build_public_audit_export_includes_v2_root_metadata(self) -> None: + election, _candidate, manifest_digest, anchor_hash = self._create_coherent_v2_election() + + payload = elections_services.build_public_audit_export(election=election) + + self.assertEqual(payload["chain_version"], 2) + self.assertEqual(payload["chain_root_kind"], "config_anchor_v2") + self.assertNotIn("chain_root_hash", payload) + self.assertNotIn("chain_anchor_hash", payload) + self.assertEqual(payload["genesis_hash"], anchor_hash) + self.assertEqual(payload["config_manifest_sha256"], manifest_digest) + self.assertEqual(payload["audit_log"][0]["payload"]["config_manifest_sha256"], manifest_digest) + self.assertNotIn("chain_root_hash", payload["audit_log"][0]["payload"]) + + def test_build_public_ballots_export_uses_genesis_hash_as_only_top_level_origin_for_v1(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Artifact election (v1 origin metadata)", + description="", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.closed, + chain_version=1, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + + genesis_hash = election_genesis_chain_hash(election.id) + ballot_hash = Ballot.compute_hash( + election_id=election.id, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + nonce="0" * 32, + ) + chain_hash = compute_chain_hash(previous_chain_hash=genesis_hash, ballot_hash=ballot_hash) + Ballot.objects.create( + election=election, + credential_public_id="cred-1", + ranking=[candidate.id], + weight=1, + ballot_hash=ballot_hash, + previous_chain_hash=genesis_hash, + chain_hash=chain_hash, + ) + + payload = elections_services.build_public_ballots_export(election=election) + + self.assertEqual(payload["chain_version"], 1) + self.assertEqual(payload["chain_root_kind"], "legacy_genesis") + self.assertEqual(payload["genesis_hash"], genesis_hash) + self.assertNotIn("chain_root_hash", payload) + + def test_persist_public_election_artifacts_rejects_inconsistent_v2_manifest_state(self) -> None: + for mutation in ("manifest", "digest", "anchor"): + election, _candidate, manifest_digest, anchor_hash = self._create_coherent_v2_election( + include_ballot=True, + ) + + if mutation == "manifest": + tampered_manifest = json.loads(json.dumps(election.config_manifest)) + tampered_manifest["election"]["name"] = "Tampered" + Election.objects.filter(pk=election.pk).update(config_manifest=tampered_manifest) + elif mutation == "digest": + Election.objects.filter(pk=election.pk).update(config_manifest_sha256="0" * 64) + else: + self.assertNotEqual(anchor_hash, "f" * 64) + Election.objects.filter(pk=election.pk).update(chain_anchor_hash="f" * 64) + + election.refresh_from_db() + with self.subTest(mutation=mutation): + with self.assertRaises(ValueError): + elections_services.persist_public_election_artifacts(election=election) + + # Ensure the helper values are actually coherent before tampering to avoid false positives. + self.assertEqual(len(manifest_digest), 64) + + def test_build_public_ballots_export_rejects_v2_manifest_missing_ranked_candidate(self) -> None: + election, candidate, _manifest_digest, _anchor_hash = self._create_coherent_v2_election( + status=Election.Status.closed, + include_ballot=True, + ) + tampered_manifest = json.loads(json.dumps(election.config_manifest)) + tampered_manifest["candidates"] = [] + tampered_digest = config_manifest_sha256(tampered_manifest) + tampered_anchor = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=tampered_digest, + ) + Election.objects.filter(pk=election.pk).update( + config_manifest=tampered_manifest, + config_manifest_sha256=tampered_digest, + chain_anchor_hash=tampered_anchor, + ) + election.refresh_from_db() + + with self.assertRaisesRegex(ValueError, str(candidate.id)): + elections_services.build_public_ballots_export(election=election) diff --git a/astra_app/core/tests/test_elections_audit_log_page.py b/astra_app/core/tests/test_elections_audit_log_page.py index 377c3d1b..9d6988bd 100644 --- a/astra_app/core/tests/test_elections_audit_log_page.py +++ b/astra_app/core/tests/test_elections_audit_log_page.py @@ -7,11 +7,12 @@ from django.utils import timezone from core import elections_services +from core.election_chain import build_config_manifest, config_manifest_sha256 from core.freeipa.user import FreeIPAUser from core.models import AuditLogEntry, Ballot, Candidate, Election, FreeIPAPermissionGrant from core.permissions import ASTRA_ADD_ELECTION from core.tests.ballot_chain import compute_chain_hash -from core.tokens import election_genesis_chain_hash +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash from core.views_elections._helpers import _elected_candidate_display @@ -106,6 +107,7 @@ def test_audit_log_api_returns_timeline_with_tally_rounds(self) -> None: end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create( election=election, @@ -156,6 +158,138 @@ def test_audit_log_api_returns_timeline_with_tally_rounds(self) -> None: self.assertEqual(summary_resp.status_code, 200) self.assertEqual(str(summary_resp.json()["summary"]["quota"]), "1") + def test_audit_log_api_exposes_v2_start_root_metadata(self) -> None: + self._login_as_freeipa_user("viewer") + + now = timezone.now() + election = Election.objects.create( + name="Audit log v2 metadata", + description="", + url="https://example.com/elections/audit-v2", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.closed, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + AuditLogEntry.objects.create( + election=election, + event_type="election_started", + payload={ + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + "candidates": [ + { + "id": candidate.id, + "freeipa_username": candidate.freeipa_username, + "tiebreak_uuid": str(candidate.tiebreak_uuid), + } + ], + }, + is_public=True, + ) + + viewer = FreeIPAUser("viewer", {"uid": ["viewer"], "memberof_group": []}) + with patch("core.freeipa.user.FreeIPAUser.get", return_value=viewer): + api_resp = self.client.get(reverse("api-election-audit-log", args=[election.id]), HTTP_ACCEPT="application/json") + + self.assertEqual(api_resp.status_code, 200) + started = next(item for item in api_resp.json()["audit_log"]["items"] if item["event_type"] == "election_started") + self.assertEqual(started["payload"]["chain_version"], 2) + self.assertEqual(started["payload"]["config_manifest_version"], 1) + self.assertEqual(started["payload"]["config_manifest_sha256"], manifest_digest) + self.assertEqual(started["payload"]["genesis_hash"], anchor_hash) + self.assertNotIn("chain_anchor_hash", started["payload"]) + self.assertNotIn("chain_root_kind", started["payload"]) + self.assertNotIn("chain_root_hash", started["payload"]) + + def test_audit_log_api_derives_v2_start_candidates_from_config_manifest(self) -> None: + self._login_as_freeipa_user("viewer") + + now = timezone.now() + election = Election.objects.create( + name="Audit log v2 manifest candidates", + description="", + url="https://example.com/elections/audit-v2-candidates", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + quorum=10, + eligible_group_cn="voters", + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.closed, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + AuditLogEntry.objects.create( + election=election, + event_type="election_started", + payload={ + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + }, + is_public=True, + ) + + viewer = FreeIPAUser("viewer", {"uid": ["viewer"], "memberof_group": []}) + with patch("core.freeipa.user.FreeIPAUser.get", return_value=viewer): + api_resp = self.client.get(reverse("api-election-audit-log", args=[election.id]), HTTP_ACCEPT="application/json") + + self.assertEqual(api_resp.status_code, 200) + started = next(item for item in api_resp.json()["audit_log"]["items"] if item["event_type"] == "election_started") + self.assertEqual( + started["payload"]["candidates"], + [ + { + "id": candidate.id, + "freeipa_username": candidate.freeipa_username, + "tiebreak_uuid": str(candidate.tiebreak_uuid), + } + ], + ) + def test_audit_log_shows_full_name_and_username_for_elected_candidates_in_both_sections(self) -> None: self._login_as_freeipa_user("viewer") diff --git a/astra_app/core/tests/test_elections_edit_ui.py b/astra_app/core/tests/test_elections_edit_ui.py index 5161e8da..b4ffd6a5 100644 --- a/astra_app/core/tests/test_elections_edit_ui.py +++ b/astra_app/core/tests/test_elections_edit_ui.py @@ -793,6 +793,151 @@ def test_start_election_still_requires_required_fields_after_partial_draft_save( election.refresh_from_db() self.assertEqual(election.status, Election.Status.draft) + def test_open_v2_election_save_draft_with_new_candidate_row_does_not_500(self) -> None: + self._login_as_freeipa_user("admin") + FreeIPAPermissionGrant.objects.create( + principal_type=FreeIPAPermissionGrant.PrincipalType.user, + principal_name="admin", + permission=ASTRA_ADD_ELECTION, + ) + + now = timezone.now() + election = Election.objects.create( + name="Open v2 election", + description="", + url="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + quorum=10, + status=Election.Status.draft, + chain_version=2, + ) + existing_candidate = Candidate.objects.create( + election=election, + freeipa_username="carol", + nominated_by="dave", + ) + Election.objects.filter(pk=election.pk).update(status=Election.Status.open) + election.refresh_from_db() + + resp = self.client.post( + reverse("election-edit", args=[election.id]), + data={ + "action": "save_draft", + "name": election.name, + "description": election.description, + "url": election.url, + "start_datetime": election.start_datetime.strftime("%Y-%m-%dT%H:%M"), + "end_datetime": election.end_datetime.strftime("%Y-%m-%dT%H:%M"), + "number_of_seats": str(election.number_of_seats), + "quorum": str(election.quorum), + "eligible_group_cn": str(election.eligible_group_cn or ""), + "email_template_id": "", + "subject": "", + "html_content": "", + "text_content": "", + "candidates-TOTAL_FORMS": "2", + "candidates-INITIAL_FORMS": "1", + "candidates-MIN_NUM_FORMS": "0", + "candidates-MAX_NUM_FORMS": "1000", + "candidates-0-id": str(existing_candidate.id), + "candidates-0-freeipa_username": "carol", + "candidates-0-nominated_by": "dave", + "candidates-0-description": "", + "candidates-0-url": "", + "candidates-0-DELETE": "", + "candidates-1-id": "", + "candidates-1-freeipa_username": "alice", + "candidates-1-nominated_by": "bob", + "candidates-1-description": "", + "candidates-1-url": "", + "candidates-1-DELETE": "", + "groups-TOTAL_FORMS": "0", + "groups-INITIAL_FORMS": "0", + "groups-MIN_NUM_FORMS": "0", + "groups-MAX_NUM_FORMS": "1000", + }, + follow=False, + ) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Please correct the errors below.") + self.assertContains(resp, "This election is no longer in draft; draft changes are locked.") + self.assertFalse(Candidate.objects.filter(election=election, freeipa_username="alice").exists()) + + def test_open_v2_election_save_draft_with_new_group_row_does_not_500(self) -> None: + self._login_as_freeipa_user("admin") + FreeIPAPermissionGrant.objects.create( + principal_type=FreeIPAPermissionGrant.PrincipalType.user, + principal_name="admin", + permission=ASTRA_ADD_ELECTION, + ) + + now = timezone.now() + election = Election.objects.create( + name="Open v2 group election", + description="", + url="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + quorum=10, + status=Election.Status.draft, + chain_version=2, + ) + existing_candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="bob", + ) + Election.objects.filter(pk=election.pk).update(status=Election.Status.open) + election.refresh_from_db() + + resp = self.client.post( + reverse("election-edit", args=[election.id]), + data={ + "action": "save_draft", + "name": election.name, + "description": election.description, + "url": election.url, + "start_datetime": election.start_datetime.strftime("%Y-%m-%dT%H:%M"), + "end_datetime": election.end_datetime.strftime("%Y-%m-%dT%H:%M"), + "number_of_seats": str(election.number_of_seats), + "quorum": str(election.quorum), + "eligible_group_cn": str(election.eligible_group_cn or ""), + "email_template_id": "", + "subject": "", + "html_content": "", + "text_content": "", + "candidates-TOTAL_FORMS": "1", + "candidates-INITIAL_FORMS": "1", + "candidates-MIN_NUM_FORMS": "0", + "candidates-MAX_NUM_FORMS": "1000", + "candidates-0-id": str(existing_candidate.id), + "candidates-0-freeipa_username": "alice", + "candidates-0-nominated_by": "bob", + "candidates-0-description": "", + "candidates-0-url": "", + "candidates-0-DELETE": "", + "groups-TOTAL_FORMS": "1", + "groups-INITIAL_FORMS": "0", + "groups-MIN_NUM_FORMS": "0", + "groups-MAX_NUM_FORMS": "1000", + "groups-0-id": "", + "groups-0-name": "Employees", + "groups-0-max_elected": "1", + "groups-0-candidate_usernames": ["alice"], + "groups-0-DELETE": "", + }, + follow=False, + ) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Please correct the errors below.") + self.assertContains(resp, "This election is no longer in draft; draft changes are locked.") + self.assertFalse(ExclusionGroup.objects.filter(election=election, name="Employees").exists()) + @override_settings(ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS=1) def test_save_draft_rejects_self_nomination_and_does_not_save_candidate(self) -> None: self._login_as_freeipa_user("admin") diff --git a/astra_app/core/tests/test_elections_flow.py b/astra_app/core/tests/test_elections_flow.py index 7740eaae..dbb163d2 100644 --- a/astra_app/core/tests/test_elections_flow.py +++ b/astra_app/core/tests/test_elections_flow.py @@ -14,6 +14,7 @@ from django.utils import timezone from core import elections_services +from core.election_chain import build_config_manifest, config_manifest_sha256 from core.elections_services import ( BallotReceipt, ElectionError, @@ -36,7 +37,7 @@ from core.models import AuditLogEntry, Ballot, Candidate, Election, Membership, MembershipType, VotingCredential from core.tests.ballot_chain import compute_chain_hash from core.tests.utils_test_data import ensure_core_categories, ensure_email_templates -from core.tokens import election_genesis_chain_hash +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash def setUpModule() -> None: @@ -1721,6 +1722,57 @@ def test_vote_receipt_email_uses_recipient_timezone(self) -> None: self.assertIn("(Europe/Paris)", str(ctx.get("election_end_datetime") or "")) self.assertIn("15:00", str(ctx.get("election_end_datetime") or "")) + def test_vote_receipt_email_includes_v2_manifest_digest_context(self) -> None: + from post_office.models import Email, EmailTemplate + + EmailTemplate.objects.get_or_create( + name=settings.ELECTION_VOTE_RECEIPT_EMAIL_TEMPLATE_NAME, + defaults={ + "subject": "Receipt {{ config_manifest_sha256 }}", + "content": "Chain {{ chain_version }} Digest {{ config_manifest_sha256 }}", + "html_content": "", + }, + ) + + now = timezone.now() + election = Election.objects.create( + name="V2 receipt election", + description="", + start_datetime=now - datetime.timedelta(hours=1), + end_datetime=now + datetime.timedelta(hours=1), + number_of_seats=1, + status=Election.Status.open, + chain_version=2, + config_manifest_version=1, + config_manifest={"version": 1, "election": {}, "tally_rule": {}, "candidates": [], "exclusion_groups": []}, + config_manifest_sha256="5" * 64, + chain_anchor_hash="6" * 64, + ) + + ballot = Ballot.objects.create( + election=election, + credential_public_id="cred-1", + ranking=[], + weight=1, + ballot_hash="b" * 64, + previous_chain_hash="6" * 64, + chain_hash="1" * 64, + ) + receipt = BallotReceipt(ballot=ballot, nonce="n" * 32) + + send_vote_receipt_email( + request=None, + election=election, + username="voter1", + email="voter1@example.com", + receipt=receipt, + ) + + queued = Email.objects.get() + context = queued.context or {} + self.assertEqual(context["chain_version"], 2) + self.assertEqual(context["config_manifest_sha256"], "5" * 64) + def test_voting_credential_email_uses_recipient_timezone(self) -> None: from post_office.models import Email, EmailTemplate @@ -1847,6 +1899,7 @@ def test_vote_submit_rejects_credential_belonging_to_different_user(self) -> Non }, content_type="application/json", ) + self.assertEqual(response.status_code, 400) self.assertEqual(response.json().get("error"), "Invalid credential.") @@ -1904,8 +1957,68 @@ def test_vote_submit_credential_check_is_timing_safe(self) -> None: content_type="application/json", ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json().get("error"), "Invalid credential.") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json().get("error"), "Invalid credential.") + + def test_vote_submit_returns_v2_manifest_digest_checkpoint(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Vote election v2", + description="", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.draft, + chain_version=2, + ) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + ) + manifest = build_config_manifest(election=election) + manifest_digest = config_manifest_sha256(manifest) + anchor_hash = election_chain_anchor_hash( + election_id=election.id, + config_manifest_sha256=manifest_digest, + ) + Election.objects.filter(pk=election.pk).update( + status=Election.Status.open, + config_manifest_version=1, + config_manifest=manifest, + config_manifest_sha256=manifest_digest, + chain_anchor_hash=anchor_hash, + ) + election.refresh_from_db() + credential = VotingCredential.objects.create( + election=election, + public_id="cred-v2-1", + freeipa_username="voter1", + weight=2, + ) + + self._login_as_freeipa_user("voter1") + voter = FreeIPAUser( + "voter1", + {"uid": ["voter1"], "memberof_group": [], "mail": ["voter1@example.com"]}, + ) + + with patch("core.freeipa.user.FreeIPAUser.get", return_value=voter): + response = self.client.post( + reverse("election-vote-submit", args=[election.id]), + data=json.dumps( + { + "credential_public_id": credential.public_id, + "ranking": [candidate.id], + } + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["chain_version"], 2) + self.assertEqual(payload["config_manifest_sha256"], manifest_digest) def test_vote_submit_rejects_voter_with_revoked_membership(self) -> None: self._login_as_freeipa_user("voter1") diff --git a/astra_app/core/tests/test_elections_nomination_ssot.py b/astra_app/core/tests/test_elections_nomination_ssot.py index 5ba28067..a44ee946 100644 --- a/astra_app/core/tests/test_elections_nomination_ssot.py +++ b/astra_app/core/tests/test_elections_nomination_ssot.py @@ -4,8 +4,14 @@ from django.test import TestCase from django.utils import timezone -from core.forms_elections import CandidateWizardForm -from core.models import Candidate, Election +from core.forms_elections import ( + CandidateWizardForm, + CandidateWizardFormSet, + ElectionDetailsForm, + ExclusionGroupWizardForm, + ExclusionGroupWizardFormSet, +) +from core.models import Candidate, Election, ExclusionGroup class ElectionsNominationSSOTTests(TestCase): @@ -37,3 +43,105 @@ def test_candidate_form_uses_self_nomination_ssot_validator(self) -> None: self.assertFalse(form.is_valid()) validator_mock.assert_called_once_with(candidate_username="alice", nominator_username="alice") + + def test_election_details_form_rejects_manifest_backed_changes_for_started_v2_election(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Started v2", + description="", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.open, + chain_version=2, + ) + + form = ElectionDetailsForm( + data={ + "name": "Tampered", + "description": "", + "url": "", + "start_datetime": election.start_datetime.strftime("%Y-%m-%dT%H:%M"), + "end_datetime": election.end_datetime.strftime("%Y-%m-%dT%H:%M"), + "number_of_seats": 1, + "quorum": 10, + "eligible_group_cn": "", + }, + instance=election, + ) + + self.assertFalse(form.is_valid()) + self.assertIn("name", form.errors) + + def test_exclusion_group_form_rejects_manifest_backed_changes_for_started_v2_election(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Started v2", + description="", + start_datetime=now - datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.draft, + chain_version=2, + ) + group = ExclusionGroup.objects.create(election=election, name="Employees", max_elected=1) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + form = ExclusionGroupWizardForm( + data={ + "name": "Employees", + "max_elected": 2, + "candidate_usernames": [], + }, + instance=group, + ) + + self.assertFalse(form.is_valid()) + self.assertIn("max_elected", form.errors) + + def test_candidate_formset_validation_allows_new_row_before_election_assignment(self) -> None: + formset = CandidateWizardFormSet( + data={ + "candidates-TOTAL_FORMS": "1", + "candidates-INITIAL_FORMS": "0", + "candidates-MIN_NUM_FORMS": "0", + "candidates-MAX_NUM_FORMS": "1000", + "candidates-0-id": "", + "candidates-0-freeipa_username": "alice", + "candidates-0-nominated_by": "bob", + "candidates-0-description": "", + "candidates-0-url": "", + "candidates-0-DELETE": "", + }, + queryset=Candidate.objects.none(), + prefix="candidates", + ) + + for form in formset.forms: + form.fields["freeipa_username"].choices = [("alice", "alice")] + form.fields["nominated_by"].choices = [("bob", "bob")] + + self.assertTrue(formset.is_valid(), formset.errors) + + def test_exclusion_group_formset_validation_allows_new_row_before_election_assignment(self) -> None: + formset = ExclusionGroupWizardFormSet( + data={ + "groups-TOTAL_FORMS": "1", + "groups-INITIAL_FORMS": "0", + "groups-MIN_NUM_FORMS": "0", + "groups-MAX_NUM_FORMS": "1000", + "groups-0-id": "", + "groups-0-name": "Employees", + "groups-0-max_elected": "1", + "groups-0-candidate_usernames": ["alice"], + "groups-0-DELETE": "", + }, + queryset=ExclusionGroup.objects.none(), + prefix="groups", + ) + + for form in formset.forms: + form.fields["candidate_usernames"].choices = [("alice", "alice")] + + self.assertTrue(formset.is_valid(), formset.errors) diff --git a/astra_app/core/tests/test_elections_tiebreak_hardening.py b/astra_app/core/tests/test_elections_tiebreak_hardening.py index aa3450a5..82d6163f 100644 --- a/astra_app/core/tests/test_elections_tiebreak_hardening.py +++ b/astra_app/core/tests/test_elections_tiebreak_hardening.py @@ -13,17 +13,19 @@ from unittest.mock import patch from django.core.exceptions import ValidationError -from django.test import TestCase +from django.db import DatabaseError, connection +from django.test import TestCase, TransactionTestCase from django.urls import reverse from django.utils import timezone +from core.election_chain import CHAIN_VERSION_CONFIG_ANCHOR_V2 from core.freeipa.user import FreeIPAUser -from core.models import AuditLogEntry, Ballot, Candidate, Election +from core.models import AuditLogEntry, Ballot, Candidate, Election, ExclusionGroup, ExclusionGroupCandidate from core.tests.ballot_chain import compute_chain_hash from core.tokens import election_genesis_chain_hash -def _make_election(status: str) -> Election: +def _make_election(status: str, *, chain_version: int = 1) -> Election: now = timezone.now() return Election.objects.create( name=f"Tiebreak hardening test ({status})", @@ -32,6 +34,7 @@ def _make_election(status: str) -> Election: end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=status, + chain_version=chain_version, ) @@ -46,6 +49,26 @@ def _make_candidate(election: Election, username: str = "alice") -> Candidate: class CandidateTiebreakImmutabilityTests(TestCase): """Candidate.save() must reject tiebreak_uuid changes on non-draft elections.""" + def test_election_chain_version_defaults_to_newest_on_create(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Default chain version election", + description="", + start_datetime=now + datetime.timedelta(days=1), + end_datetime=now + datetime.timedelta(days=2), + number_of_seats=1, + status=Election.Status.draft, + ) + + self.assertEqual(election.chain_version, CHAIN_VERSION_CONFIG_ANCHOR_V2) + + def test_chain_version_immutable_on_draft_election_after_creation(self) -> None: + election = _make_election(Election.Status.draft, chain_version=1) + election.chain_version = CHAIN_VERSION_CONFIG_ANCHOR_V2 + + with self.assertRaises(ValidationError): + election.save() + def test_tiebreak_uuid_immutable_for_open_election(self) -> None: election = _make_election(Election.Status.open) candidate = _make_candidate(election) @@ -94,6 +117,121 @@ def test_save_other_fields_allowed_on_tallied_election(self) -> None: candidate.refresh_from_db() self.assertEqual(candidate.description, "Post-tally edit") + def test_v2_candidate_manifest_backed_fields_are_immutable_for_open_election(self) -> None: + election = _make_election(Election.Status.draft, chain_version=2) + candidate = _make_candidate(election) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + candidate.freeipa_username = "mallory" + with self.assertRaises(ValidationError): + candidate.save() + + def test_v2_candidate_description_and_url_remain_editable_after_start(self) -> None: + election = _make_election(Election.Status.draft, chain_version=2) + candidate = Candidate.objects.create( + election=election, + freeipa_username="alice", + nominated_by="nominator", + description="before", + url="https://example.com/candidates/before", + ) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + candidate.description = "after" + candidate.url = "https://example.com/candidates/after" + candidate.save() + + candidate.refresh_from_db() + self.assertEqual(candidate.description, "after") + self.assertEqual(candidate.url, "https://example.com/candidates/after") + + def test_v2_election_manifest_backed_fields_are_immutable_after_start(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Started v2", + description="", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.open, + chain_version=2, + ) + + election.name = "Tampered" + + with self.assertRaises(ValidationError): + election.save() + + def test_v2_election_description_and_url_remain_editable_after_start(self) -> None: + now = timezone.now() + election = Election.objects.create( + name="Started v2 mutable metadata", + description="before", + url="https://example.com/before", + start_datetime=now - datetime.timedelta(days=2), + end_datetime=now - datetime.timedelta(days=1), + number_of_seats=1, + status=Election.Status.open, + chain_version=2, + ) + + election.description = "after" + election.url = "https://example.com/after" + election.save() + + election.refresh_from_db() + self.assertEqual(election.description, "after") + self.assertEqual(election.url, "https://example.com/after") + + def test_v2_exclusion_group_fields_are_immutable_after_start(self) -> None: + election = _make_election(Election.Status.draft, chain_version=2) + candidate = _make_candidate(election) + group = ExclusionGroup.objects.create(election=election, name="Employees", max_elected=1) + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + group.max_elected = 2 + + with self.assertRaises(ValidationError): + group.save() + + def test_v2_exclusion_group_membership_is_immutable_after_start(self) -> None: + election = _make_election(Election.Status.draft, chain_version=2) + candidate = _make_candidate(election) + other_candidate = _make_candidate(election, username="bob") + group = ExclusionGroup.objects.create(election=election, name="Employees", max_elected=1) + membership = ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + election.status = Election.Status.open + election.save(update_fields=["status", "updated_at"]) + + with self.assertRaises(ValidationError): + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=other_candidate) + + with self.assertRaises(ValidationError): + membership.delete() + + +class ElectionChainVersionDatabaseTriggerTests(TransactionTestCase): + """Database-level trigger must reject ORM-bypassing chain_version writes.""" + + def test_chain_version_direct_sql_update_is_rejected_after_insert(self) -> None: + if connection.vendor != "postgresql": + self.skipTest("Election.chain_version trigger coverage requires PostgreSQL.") + + election = _make_election(Election.Status.draft, chain_version=1) + + with self.assertRaises(DatabaseError): + with connection.cursor() as cursor: + cursor.execute( + "UPDATE core_election SET chain_version = %s WHERE id = %s", + [CHAIN_VERSION_CONFIG_ANCHOR_V2, election.id], + ) + + election.refresh_from_db() + self.assertEqual(election.chain_version, 1) + class ElectionStartedAuditPayloadTests(TestCase): """election_started AuditLogEntry payload must include candidate tiebreak UUIDs.""" @@ -206,6 +344,7 @@ def _make_tallied_election() -> Election: end_datetime=now - datetime.timedelta(days=1), number_of_seats=1, status=Election.Status.closed, + chain_version=1, ) c1 = Candidate.objects.create(election=election, freeipa_username="alice", nominated_by="nominator") c2 = Candidate.objects.create(election=election, freeipa_username="bob", nominated_by="nominator") diff --git a/astra_app/core/tests/test_elections_timestamping.py b/astra_app/core/tests/test_elections_timestamping.py index b5192146..0aa3a24f 100644 --- a/astra_app/core/tests/test_elections_timestamping.py +++ b/astra_app/core/tests/test_elections_timestamping.py @@ -382,8 +382,9 @@ def test_failure_event_payload_contains_only_error_type(self) -> None: def test_build_public_audit_export_includes_timestamping_block(self) -> None: election = self._create_election() + event_timestamp = datetime.datetime(2026, 1, 2, 3, 4, 1, tzinfo=datetime.UTC) integrated_time = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC) - AuditLogEntry.objects.create( + entry = AuditLogEntry.objects.create( election=election, event_type="election_closed", payload={"chain_head": "abc", "actor": "alice"}, @@ -395,6 +396,7 @@ def test_build_public_audit_export_includes_timestamping_block(self) -> None: rekor_message_digest_hex="a" * 64, rekor_canonical_message_version=1, ) + AuditLogEntry.objects.filter(pk=entry.pk).update(timestamp=event_timestamp) payload = build_public_audit_export(election=election) event = payload["audit_log"][0] @@ -410,3 +412,4 @@ def test_build_public_audit_export_includes_timestamping_block(self) -> None: ) self.assertEqual(event["timestamping"]["message_digest_hex"], "a" * 64) self.assertEqual(event["timestamping"]["canonical_message_version"], 1) + self.assertEqual(event["timestamp_utc"], "2026-01-02T03:04:01Z") diff --git a/astra_app/core/tests/test_templated_email_template_drift.py b/astra_app/core/tests/test_templated_email_template_drift.py index de6d79d6..8d3abeda 100644 --- a/astra_app/core/tests/test_templated_email_template_drift.py +++ b/astra_app/core/tests/test_templated_email_template_drift.py @@ -44,3 +44,40 @@ def test_vote_receipt_template_missing_weight_logs_warning(self) -> None: any("missing required" in line.lower() and "weight" in line.lower() for line in logs.output), f"expected warning about missing weight, got: {logs.output}", ) + + @override_settings(ELECTION_VOTE_RECEIPT_EMAIL_TEMPLATE_NAME="vote-receipt-drift-test") + def test_vote_receipt_template_missing_manifest_digest_logs_warning(self) -> None: + from post_office.models import EmailTemplate + + from core.templated_email import queue_templated_email + + EmailTemplate.objects.create( + name="vote-receipt-drift-test", + subject="Receipt {{ ballot_hash }}", + content="Receipt {{ ballot_hash }} Nonce {{ nonce }} Weight {{ weight }}", + html_content="

    Receipt {{ ballot_hash }}

    {{ chain_hash }}

    {{ previous_chain_hash }}

    ", + ) + + with ( + patch("post_office.mail.send", autospec=True), + self.assertLogs("core.templated_email", level="WARNING") as logs, + ): + queue_templated_email( + recipients=["alice@example.com"], + sender="noreply@example.com", + template_name="vote-receipt-drift-test", + context={ + "username": "alice", + "ballot_hash": "a" * 64, + "nonce": "n" * 32, + "weight": 1, + "chain_hash": "c" * 64, + "previous_chain_hash": "p" * 64, + "config_manifest_sha256": "d" * 64, + }, + ) + + self.assertTrue( + any("missing required" in line.lower() and "config_manifest_sha256" in line.lower() for line in logs.output), + f"expected warning about missing config_manifest_sha256, got: {logs.output}", + ) diff --git a/astra_app/core/tests/test_verification_scripts.py b/astra_app/core/tests/test_verification_scripts.py index 792d465b..61a28c7c 100644 --- a/astra_app/core/tests/test_verification_scripts.py +++ b/astra_app/core/tests/test_verification_scripts.py @@ -1,19 +1,25 @@ import base64 +import datetime import hashlib import importlib.util import io import json import random +import textwrap +from collections.abc import Mapping from contextlib import redirect_stdout from pathlib import Path +from typing import cast from unittest.mock import patch from urllib.error import URLError +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec from django.test import SimpleTestCase from core.models import Ballot -from core.tokens import election_chain_next_hash, election_genesis_chain_hash +from core.tokens import election_chain_anchor_hash, election_chain_next_hash, election_genesis_chain_hash def _load_script_module(*, name: str, path: Path): @@ -27,6 +33,194 @@ def _load_script_module(*, name: str, path: Path): class VerificationScriptsTests(SimpleTestCase): + TRUSTED_REKOR_PUBLIC_KEY_PEM = textwrap.dedent( + """\ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6VF2PVPmOMIg5eRV1+MIK/hRXy53 + 4wHhKn77HEEZP5mfbkOtEkVEVsO8W4X0dsubDOlAcx49ckaAH/KGMsHPkQ== + -----END PUBLIC KEY----- + """ + ).strip() + TRUSTED_REKOR_PUBLIC_KEY_SHA256 = "99f3b7b90a6d81ac36e8aaf8066d3da0d7ccd49dab06995ad6eeb83384a0dd12" + + def _build_valid_v1_manifest(self, *, election_id: int = 7, name: str = "Election 7") -> dict[str, object]: + return { + "version": 1, + "election": { + "id": election_id, + "name": name, + "start_datetime": "2026-01-02T03:04:05Z", + "number_of_seats": 1, + "quorum": 10, + "eligible_group_cn": "voters", + }, + "tally_rule": { + "algorithm": "Meek STV (High-Precision Variant)", + "algorithm_version": "1.0", + "spec_identity": "docs/runbooks/meek-stv-elections.md", + "epsilon": "1E-28", + "max_iterations": 200, + }, + "candidates": [ + { + "id": 1, + "freeipa_username": "alice", + "nominated_by": "nominator", + "tiebreak_uuid": "00000000-0000-0000-0000-000000000001", + } + ], + "exclusion_groups": [ + { + "public_id": "10000000-0000-0000-0000-000000000001", + "name": "Employees", + "max_elected": 1, + "candidate_ids": [1], + } + ], + } + + def _generate_signing_material(self) -> tuple[ec.EllipticCurvePrivateKey, str, str]: + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + public_key_pem = public_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + public_key_der = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_key_fingerprint = hashlib.sha256(public_key_der).hexdigest() + return private_key, public_key_pem, public_key_fingerprint + + def _rekor_wrapper_response( + self, + *, + module, + canonical_bytes: bytes, + expected_digest: str, + signing_key: ec.EllipticCurvePrivateKey, + integrated_time: int | None = None, + ) -> dict[str, dict[str, object]]: + signature_der = signing_key.sign(canonical_bytes, ec.ECDSA(hashes.SHA256())) + public_key_pem = signing_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + resolved_integrated_time = integrated_time + if resolved_integrated_time is None: + resolved_integrated_time = int(datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC).timestamp()) + body = { + "apiVersion": "0.0.1", + "kind": "hashedrekord", + "spec": { + "data": { + "hash": { + "algorithm": "sha256", + "value": expected_digest, + } + }, + "signature": { + "content": base64.b64encode(signature_der).decode("ascii"), + "publicKey": { + "content": base64.b64encode(public_key_pem).decode("ascii"), + }, + }, + }, + } + return { + "uuid-1": { + "integratedTime": resolved_integrated_time, + "body": base64.b64encode(json.dumps(body).encode("utf-8")).decode("ascii"), + } + } + + def _fake_urlopen_response(self, wrapper_response: Mapping[str, object]): + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + del exc_type + del exc + del tb + return False + + def read(self) -> bytes: + return json.dumps(wrapper_response).encode("utf-8") + + return _FakeResponse() + + def _build_v2_bundle_with_optional_ballot( + self, + *, + election_id: int = 7, + include_ballot: bool = False, + ) -> tuple[dict[str, object], dict[str, object], dict[str, str]]: + manifest = self._build_valid_v1_manifest(election_id=election_id) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=election_id, config_manifest_sha256=manifest_digest) + + ballot_hash = "" + chain_head = anchor_hash + ballots: list[dict[str, str]] = [] + if include_ballot: + ballot_hash = Ballot.compute_hash( + election_id=election_id, + credential_public_id="cred-1", + ranking=[1], + weight=1, + nonce="0" * 32, + ) + chain_head = election_chain_next_hash(previous_chain_hash=anchor_hash, ballot_hash=ballot_hash) + ballots.append( + { + "ballot_hash": ballot_hash, + "previous_chain_hash": anchor_hash, + "chain_hash": chain_head, + } + ) + + ballots_export: dict[str, object] = { + "election_id": election_id, + "chain_version": 2, + "chain_root_kind": "config_anchor_v2", + "genesis_hash": anchor_hash, + "chain_head": chain_head, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "ballots": ballots, + } + audit_export: dict[str, object] = { + "election_id": election_id, + "chain_version": 2, + "chain_root_kind": "config_anchor_v2", + "genesis_hash": anchor_hash, + "chain_head": chain_head, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "audit_log": [ + { + "event_type": "election_started", + "payload": { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + }, + } + ], + } + return ballots_export, audit_export, { + "manifest_digest": manifest_digest, + "anchor_hash": anchor_hash, + "ballot_hash": ballot_hash, + "chain_head": chain_head, + } + def test_verify_ballot_hash_script_matches_ballot_compute_hash(self) -> None: module = _load_script_module( name="verify_ballot_hash", @@ -132,6 +326,15 @@ def test_verify_ballot_chain_script_rekor_canonical_digest_check_passes(self) -> self.assertIn("digest", output.getvalue().lower()) + def test_verify_audit_log_script_ships_with_default_trusted_attestation_signer(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + self.assertEqual(module.trusted_public_key_pem.strip(), self.TRUSTED_REKOR_PUBLIC_KEY_PEM) + self.assertEqual(module.trusted_public_key_sha256, self.TRUSTED_REKOR_PUBLIC_KEY_SHA256) + def test_verify_ballot_chain_script_rekor_network_error_warns_without_exit(self) -> None: module = _load_script_module( name="verify_audit_log", @@ -167,6 +370,676 @@ def test_verify_ballot_chain_script_rekor_network_error_warns_without_exit(self) self.assertIn("warning", output.getvalue().lower()) + def test_verify_ballot_chain_script_reports_v2_untrusted_local_only(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + + result = module.verify_export_bundle( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + verify_rekor_online=False, + ) + + self.assertEqual(result["status"], "untrusted_local_only") + + def test_verify_ballot_chain_script_exposes_top_level_receipt_and_rekor_config(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + self.assertTrue(hasattr(module, "receipt_previous_ledger_hash")) + self.assertTrue(hasattr(module, "receipt_current_ledger_hash")) + self.assertTrue(hasattr(module, "final_election_chain_head")) + self.assertTrue(hasattr(module, "verify_rekor_online")) + + def test_verify_ballot_chain_script_rejects_missing_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + del ballots_export["config_manifest_version"] + + with self.assertRaisesRegex(ValueError, "config_manifest_version"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_rejects_unknown_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + ballots_export["config_manifest_version"] = 2 + audit_export["config_manifest_version"] = 2 + audit_log = cast(list[object], audit_export["audit_log"]) + election_started = cast(dict[str, object], audit_log[0]) + payload = cast(dict[str, object], election_started["payload"]) + manifest = cast(dict[str, object], payload["config_manifest"]) + payload["config_manifest_version"] = 2 + manifest["version"] = 2 + + with self.assertRaisesRegex(ValueError, "config_manifest_version"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_rejects_mismatched_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + audit_log = cast(list[object], audit_export["audit_log"]) + election_started = cast(dict[str, object], audit_log[0]) + payload = cast(dict[str, object], election_started["payload"]) + payload["config_manifest_version"] = 2 + + with self.assertRaisesRegex(ValueError, "config_manifest_version"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_finds_v2_receipt_and_returns_hashes(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot( + election_id=7, + include_ballot=True, + ) + + result = module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code=metadata["ballot_hash"], + previous_ledger_hash="", + current_ledger_hash="", + receipt_previous_ledger_hash=metadata["anchor_hash"], + receipt_current_ledger_hash=metadata["chain_head"], + final_election_chain_head=metadata["chain_head"], + verify_rekor_online=True, + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertTrue(result["receipt_found"]) + self.assertEqual(result["receipt_previous_chain_hash"], metadata["anchor_hash"]) + self.assertEqual(result["receipt_chain_hash"], metadata["chain_head"]) + self.assertTrue(result["rekor_online_requested"]) + self.assertIn("verify-audit-log.py", str(result["rekor_guidance"])) + + def test_verify_ballot_chain_script_rejects_v2_receipt_previous_hash_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot( + election_id=7, + include_ballot=True, + ) + + with self.assertRaisesRegex(ValueError, "receipt previous ledger hash"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code=metadata["ballot_hash"], + previous_ledger_hash="", + current_ledger_hash="", + receipt_previous_ledger_hash="0" * 64, + final_election_chain_head=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_rejects_v2_receipt_current_hash_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot( + election_id=7, + include_ballot=True, + ) + + with self.assertRaisesRegex(ValueError, "receipt current ledger hash"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code=metadata["ballot_hash"], + previous_ledger_hash="", + current_ledger_hash="", + receipt_previous_ledger_hash=metadata["anchor_hash"], + receipt_current_ledger_hash="0" * 64, + final_election_chain_head=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_wraps_v2_bundle_for_cli_reporting(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + + result = module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertEqual(result["chain_head"], metadata["chain_head"]) + self.assertEqual(result["config_manifest_sha256"], metadata["manifest_digest"]) + self.assertEqual(result["genesis_hash"], metadata["anchor_hash"]) + self.assertNotIn("chain_anchor_hash", result) + self.assertNotIn("chain_root_hash", result) + + def test_verify_ballot_chain_script_accepts_legacy_v2_chain_root_hash_alias(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + del ballots_export["genesis_hash"] + del audit_export["genesis_hash"] + ballots_export["chain_root_hash"] = metadata["anchor_hash"] + audit_export["chain_root_hash"] = metadata["anchor_hash"] + + result = module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertEqual(result["genesis_hash"], metadata["anchor_hash"]) + self.assertNotIn("chain_anchor_hash", result) + self.assertNotIn("chain_root_hash", result) + + def test_verify_ballot_chain_script_accepts_legacy_v2_chain_anchor_hash_alias_without_genesis_hash( + self, + ) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + del ballots_export["genesis_hash"] + del audit_export["genesis_hash"] + ballots_export["chain_anchor_hash"] = metadata["anchor_hash"] + audit_export["chain_anchor_hash"] = metadata["anchor_hash"] + + result = module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertEqual(result["genesis_hash"], metadata["anchor_hash"]) + self.assertNotIn("chain_anchor_hash", result) + self.assertNotIn("chain_root_hash", result) + + def test_verify_ballot_chain_script_rejects_mismatched_legacy_v2_chain_root_hash_alias(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + ballots_export["chain_root_hash"] = "0" * 64 + audit_export["chain_root_hash"] = "0" * 64 + + with self.assertRaisesRegex( + ValueError, + r"public-ballots\.json chain_root_hash alias does not match genesis_hash", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_guides_v2_current_hash_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, _metadata = self._build_v2_bundle_with_optional_ballot( + election_id=7, + include_ballot=True, + ) + + with self.assertRaisesRegex( + ValueError, + "Entered current ledger hash does not match public-ballots.json chain_head", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash="0" * 64, + ) + + def test_verify_ballot_chain_script_rejects_v1_root_kind_contract_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + election_id = 77 + genesis = election_genesis_chain_hash(election_id) + ballot_hash = Ballot.compute_hash( + election_id=election_id, + credential_public_id="cred-1", + ranking=[1], + weight=1, + nonce="0" * 32, + ) + chain_hash = election_chain_next_hash(previous_chain_hash=genesis, ballot_hash=ballot_hash) + + with self.assertRaisesRegex(ValueError, "chain_root_kind"): + module.verify_public_ballot_export( + ballots_export={ + "election_id": election_id, + "chain_version": 1, + "chain_root_kind": "genesis_v1", + "genesis_hash": genesis, + "chain_head": chain_hash, + "ballots": [ + { + "ballot_hash": ballot_hash, + "previous_chain_hash": genesis, + "chain_hash": chain_hash, + } + ], + }, + audit_export=None, + election_id=election_id, + ballot_receipt_code=ballot_hash, + previous_ledger_hash=genesis, + current_ledger_hash=chain_hash, + ) + + def test_verify_ballot_chain_script_guides_v1_current_hash_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + election_id = 77 + genesis = election_genesis_chain_hash(election_id) + ballot_hash = Ballot.compute_hash( + election_id=election_id, + credential_public_id="cred-1", + ranking=[1], + weight=1, + nonce="0" * 32, + ) + chain_hash = election_chain_next_hash(previous_chain_hash=genesis, ballot_hash=ballot_hash) + + with self.assertRaisesRegex( + ValueError, + "Entered current ledger hash does not match public-ballots.json chain_head", + ): + module.verify_public_ballot_export( + ballots_export={ + "election_id": election_id, + "chain_version": 1, + "chain_root_kind": "legacy_genesis", + "genesis_hash": genesis, + "chain_head": chain_hash, + "ballots": [ + { + "ballot_hash": ballot_hash, + "previous_chain_hash": genesis, + "chain_hash": chain_hash, + } + ], + }, + audit_export=None, + election_id=election_id, + ballot_receipt_code=ballot_hash, + previous_ledger_hash=genesis, + current_ledger_hash="0" * 64, + ) + + def test_verify_ballot_chain_script_accepts_v1_genesis_hash_canonical_field(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + election_id = 77 + genesis = election_genesis_chain_hash(election_id) + ballot_hash = Ballot.compute_hash( + election_id=election_id, + credential_public_id="cred-1", + ranking=[1], + weight=1, + nonce="0" * 32, + ) + chain_hash = election_chain_next_hash(previous_chain_hash=genesis, ballot_hash=ballot_hash) + + result = module.verify_public_ballot_export( + ballots_export={ + "election_id": election_id, + "chain_version": 1, + "chain_root_kind": "legacy_genesis", + "genesis_hash": genesis, + "chain_head": chain_hash, + "ballots": [ + { + "ballot_hash": ballot_hash, + "previous_chain_hash": genesis, + "chain_hash": chain_hash, + } + ], + }, + audit_export=None, + election_id=election_id, + ballot_receipt_code=ballot_hash, + previous_ledger_hash=genesis, + current_ledger_hash=chain_hash, + ) + + self.assertEqual(result["status"], "valid") + self.assertEqual(result["genesis_hash"], genesis) + self.assertNotIn("chain_root_hash", result) + + def test_verify_ballot_chain_script_rejects_requested_v2_election_id_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + ballots_export["election_id"] = 8 + audit_export["election_id"] = 8 + + with self.assertRaisesRegex( + ValueError, + r"election_id mismatch: exports do not match the requested election_id; use the public-ballots\.json/public-audit\.json pair for this election\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_guides_v2_chain_version_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + audit_export["chain_version"] = 1 + + with self.assertRaisesRegex( + ValueError, + r"chain_version mismatch between public-ballots\.json and public-audit\.json; use the matching public-ballots/public-audit export pair for this election\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_requires_v2_audit_log(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + del audit_export["audit_log"] + + with self.assertRaisesRegex( + ValueError, + r"public-audit\.json must include audit_log for v2 verification; use the matching public-audit\.json exported with this public-ballots\.json\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_guides_v2_chain_root_kind_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + ballots_export["chain_root_kind"] = "legacy_genesis" + audit_export["chain_root_kind"] = "legacy_genesis" + + with self.assertRaisesRegex( + ValueError, + r"v2 exports must use chain_root_kind=config_anchor_v2; use the matching public-ballots/public-audit export pair for this election\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_rejects_mismatched_publication_bundle_timestamp(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + ballots_export["publication_bundle"] = {"published_at": "2026-04-11T10:15:00Z"} + audit_export["publication_bundle"] = {"published_at": "2026-04-11T10:16:00Z"} + + with self.assertRaisesRegex( + ValueError, + r"publication_bundle\.published_at mismatch between public-ballots\.json and public-audit\.json; use files from the same published bundle\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_ballot_chain_script_guides_chain_head_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + election_id = 77 + genesis = election_genesis_chain_hash(election_id) + ballot_hash = Ballot.compute_hash( + election_id=election_id, + credential_public_id="cred-1", + ranking=[1], + weight=1, + nonce="0" * 32, + ) + chain_hash = election_chain_next_hash(previous_chain_hash=genesis, ballot_hash=ballot_hash) + exported_chain_head = "f" * 64 + + with self.assertRaisesRegex( + ValueError, + r"chain head mismatch: reconstructed chain does not match exported chain_head; re-download the published export\(s\) and rerun verification\.", + ): + module.verify_public_ballot_export( + ballots_export={ + "election_id": election_id, + "chain_version": 1, + "chain_root_kind": "legacy_genesis", + "genesis_hash": genesis, + "chain_head": exported_chain_head, + "ballots": [ + { + "ballot_hash": ballot_hash, + "previous_chain_hash": genesis, + "chain_hash": chain_hash, + } + ], + }, + audit_export=None, + election_id=election_id, + ballot_receipt_code=ballot_hash, + previous_ledger_hash=genesis, + current_ledger_hash=exported_chain_head, + ) + + def test_verify_ballot_chain_script_guides_chain_anchor_mismatch(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + mismatched_anchor = "0" * 64 + ballots_export["genesis_hash"] = mismatched_anchor + audit_export["genesis_hash"] = mismatched_anchor + + with self.assertRaisesRegex( + ValueError, + r"genesis_hash mismatch: computed genesis hash does not match exported genesis_hash; use the matching public-audit\.json and config manifest for this election\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_audit_log_reports_v2_genesis_hash_mismatch_reason(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "genesis_hash": "0" * 64, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertEqual(result["reason"], "genesis_hash mismatch") + + def test_verify_ballot_chain_script_guides_missing_v2_election_started_event(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + audit_export["audit_log"] = [] + + with self.assertRaisesRegex( + ValueError, + r"public-audit\.json missing election_started event for v2 verification; use the matching public-audit\.json exported with this public-ballots\.json\.", + ): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + def test_verify_audit_log_script_runs_offline_by_default(self) -> None: module = _load_script_module( name="verify_audit_log", @@ -208,6 +1081,833 @@ def test_verify_audit_log_script_runs_offline_by_default(self) -> None: urlopen.assert_not_called() self.assertNotIn("online:", output.getvalue().lower()) + def test_verify_audit_log_reports_v2_election_definition_as_untrusted_local_only(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertEqual(result["chain_version"], 2) + self.assertEqual(result["config_manifest_sha256"], manifest_digest) + self.assertEqual(result["genesis_hash"], anchor_hash) + self.assertNotIn("chain_anchor_hash", result) + + def test_verify_audit_log_accepts_v2_manifest_without_candidate_description_or_url(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "untrusted_local_only") + + def test_verify_audit_log_rejects_missing_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("config_manifest_version", str(result.get("reason") or "")) + + def test_verify_audit_log_rejects_unknown_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest["version"] = 2 + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 2, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("config_manifest_version", str(result.get("reason") or "")) + + def test_verify_audit_log_rejects_mismatched_v2_config_manifest_version(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 2, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("config_manifest_version", str(result.get("reason") or "")) + + def test_verify_audit_log_marks_v2_digest_mismatch_invalid(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + }, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": "0" * 64, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("digest", result["reason"]) + + def test_verify_audit_log_online_v2_rejects_untrusted_signer(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, trusted_public_key_pem, _trusted_fingerprint = self._generate_signing_material() + attacker_key, _attacker_public_key_pem, _attacker_fingerprint = self._generate_signing_material() + del trusted_key + + manifest = self._build_valid_v1_manifest(election_id=7, name="Tampered election") + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=attacker_key, + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=True, + trusted_public_key_pem=trusted_public_key_pem, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("trusted", str(result.get("reason") or "").lower()) + + def test_verify_audit_log_online_v2_accepts_trusted_signer(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "valid") + + def test_verify_audit_log_online_v2_uses_shipped_trusted_signer_by_default(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + def fake_verify_online_rekor_entry(**kwargs): + self.assertEqual(kwargs["trusted_pem_value"].strip(), self.TRUSTED_REKOR_PUBLIC_KEY_PEM) + self.assertEqual(kwargs["trusted_fingerprint_value"], self.TRUSTED_REKOR_PUBLIC_KEY_SHA256) + integrated_time = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC) + return { + "digest_matches": True, + "rekor_digest": digest, + "embedded_public_key_sha256": self.TRUSTED_REKOR_PUBLIC_KEY_SHA256, + "integrated_time": integrated_time, + "integrated_time_utc": module._format_utc(integrated_time), + "signature_valid": True, + "trusted_configured": True, + "trusted_public_key_sha256": self.TRUSTED_REKOR_PUBLIC_KEY_SHA256, + "signer_trusted": True, + } + + with patch.object(module, "_verify_online_rekor_entry", side_effect=fake_verify_online_rekor_entry): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=True, + ) + + self.assertEqual(result["status"], "valid") + self.assertEqual(result["trusted_public_key_sha256"], self.TRUSTED_REKOR_PUBLIC_KEY_SHA256) + + def test_verify_audit_log_online_v2_allows_explicit_trust_override_over_shipped_default(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=True, + trusted_public_key_pem=trusted_public_key_pem, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "valid") + self.assertEqual(result["trusted_public_key_sha256"], trusted_fingerprint) + + def test_verify_audit_log_online_v2_rejects_untrusted_attestation_signer_with_updated_reason(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, _trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256="0" * 64, + ) + + self.assertEqual(result["status"], "invalid") + self.assertEqual( + result.get("reason"), + ( + "embedded attestation signer fingerprint does not match trusted attestation signer " + f"(embedded={module._openssl_public_key_fingerprint_sha256(pem_bytes=trusted_key.public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo))} " + "trusted=0000000000000000000000000000000000000000000000000000000000000000)" + ), + ) + + def test_verify_audit_log_online_v2_accepts_rekor_timestamp_within_tolerance(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + wrapper_response["uuid-1"]["integratedTime"] = int( + datetime.datetime(2026, 1, 2, 3, 4, 8, tzinfo=datetime.UTC).timestamp() + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + "rekor_integrated_time": "2026-01-02T03:04:08Z", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "valid") + self.assertEqual(result["rekor_timestamp_delta_seconds"], 3) + + def test_verify_audit_log_online_v2_reads_integrated_time_from_rekor_wrapper(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + integrated_time=int(datetime.datetime(2026, 1, 2, 3, 4, 7, tzinfo=datetime.UTC).timestamp()), + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + "rekor_integrated_time": "2026-01-02T03:04:07Z", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "valid") + self.assertEqual(result["rekor_integrated_time"], "2026-01-02T03:04:07Z") + + def test_verify_audit_log_online_v2_rejects_rekor_timestamp_outside_tolerance(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + wrapper_response["uuid-1"]["integratedTime"] = int( + datetime.datetime(2026, 1, 2, 3, 4, 16, tzinfo=datetime.UTC).timestamp() + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "timestamp_utc": "2026-01-02T03:04:05Z", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + "rekor_integrated_time": "2026-01-02T03:04:16Z", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("timestamp", str(result.get("reason") or "").lower()) + + def test_verify_audit_log_online_v2_without_precise_exported_timestamp_stays_untrusted(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + trusted_key, _trusted_public_key_pem, trusted_fingerprint = self._generate_signing_material() + manifest = self._build_valid_v1_manifest(election_id=7) + + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + canonical_bytes = module._canonical_bytes(event_type="election_started", payload=payload) + digest = hashlib.sha256(canonical_bytes).hexdigest() + wrapper_response = self._rekor_wrapper_response( + module=module, + canonical_bytes=canonical_bytes, + expected_digest=digest, + signing_key=trusted_key, + ) + wrapper_response["uuid-1"]["integratedTime"] = int( + datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC).timestamp() + ) + + with patch("urllib.request.urlopen", return_value=self._fake_urlopen_response(wrapper_response)): + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "timestamp": "2026-01-02", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + "rekor_integrated_time": "2026-01-02T03:04:05Z", + }, + } + ] + }, + verify_online=True, + trusted_public_key_sha256=trusted_fingerprint, + ) + + self.assertEqual(result["status"], "untrusted_local_only") + self.assertIn("precision", str(result.get("reason") or "").lower()) + + def test_verify_ballot_chain_script_rejects_incomplete_v2_manifest_schema(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + audit_log = cast(list[object], audit_export["audit_log"]) + election_started = cast(dict[str, object], audit_log[0]) + payload = cast(dict[str, object], election_started["payload"]) + manifest = cast(dict[str, object], payload["config_manifest"]) + election_section = cast(dict[str, object], manifest["election"]) + del election_section["name"] + + with self.assertRaisesRegex(ValueError, "config_manifest.*name"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + + def test_verify_audit_log_rejects_incomplete_v2_manifest_schema(self) -> None: + module = _load_script_module( + name="verify_audit_log", + path=Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py", + ) + + manifest = self._build_valid_v1_manifest(election_id=7) + tally_rule = cast(dict[str, object], manifest["tally_rule"]) + del tally_rule["algorithm"] + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + anchor_hash = election_chain_anchor_hash(election_id=7, config_manifest_sha256=manifest_digest) + payload = { + "chain_version": 2, + "config_manifest_version": 1, + "config_manifest_sha256": manifest_digest, + "chain_anchor_hash": anchor_hash, + "config_manifest": manifest, + } + digest = hashlib.sha256(module._canonical_bytes(event_type="election_started", payload=payload)).hexdigest() + + result = module.evaluate_v2_election_definition( + audit_data={ + "audit_log": [ + { + "event_type": "election_started", + "payload": payload, + "timestamping": { + "canonical_message_version": 1, + "message_digest_hex": digest, + "rekor_entry_url": "https://rekor.example/api/v1/log/entries/uuid-1", + }, + } + ] + }, + verify_online=False, + ) + + self.assertEqual(result["status"], "invalid") + self.assertIn("config_manifest", str(result.get("reason") or "")) + + def test_verify_ballot_chain_script_rejects_corrupted_v2_payload_anchor_when_top_level_matches(self) -> None: + module = _load_script_module( + name="verify_ballot_chain", + path=Path(__file__).resolve().parents[1] / "static" / "verify-ballot-chain.py", + ) + + ballots_export, audit_export, metadata = self._build_v2_bundle_with_optional_ballot(election_id=7) + audit_log = cast(list[object], audit_export["audit_log"]) + election_started = cast(dict[str, object], audit_log[0]) + payload = cast(dict[str, object], election_started["payload"]) + payload["chain_anchor_hash"] = "0" * 64 + + with self.assertRaisesRegex(ValueError, "payload.*genesis_hash"): + module.verify_public_ballot_export( + ballots_export=ballots_export, + audit_export=audit_export, + election_id=7, + ballot_receipt_code="", + previous_ledger_hash="", + current_ledger_hash=metadata["chain_head"], + ) + def test_verify_ballot_chain_script_online_warns_for_multi_key_wrapper(self) -> None: module = _load_script_module( name="verify_audit_log", diff --git a/astra_app/core/tests/test_verify_ballot_chain_script_guidance.py b/astra_app/core/tests/test_verify_ballot_chain_script_guidance.py index 2c19260f..7ffec460 100644 --- a/astra_app/core/tests/test_verify_ballot_chain_script_guidance.py +++ b/astra_app/core/tests/test_verify_ballot_chain_script_guidance.py @@ -9,21 +9,26 @@ def test_script_uses_product_wording_for_user_inputs(self) -> None: self.assertIn('ballot_receipt_code = "', content) self.assertIn('submission_nonce = "', content) - self.assertIn('previous_ledger_hash = "', content) - self.assertIn('current_ledger_hash = "', content) + self.assertIn('receipt_previous_ledger_hash = "', content) + self.assertIn('receipt_current_ledger_hash = "', content) + self.assertIn('final_election_chain_head = "', content) self.assertIn("Ballot receipt code", content) self.assertIn("Submission nonce", content) - self.assertIn("Previous ledger hash", content) - self.assertIn("Current ledger hash", content) + self.assertIn("Receipt previous ledger hash", content) + self.assertIn("Receipt current ledger hash", content) + self.assertIn("Final election chain head", content) self.assertIn("vote receipt email", content) self.assertIn("ballot verification page", content) self.assertIn("election page", content) self.assertIn("Your ballot receipt code appears", content) - self.assertIn("Exported previous ledger hash", content) + self.assertIn("Exported receipt previous ledger hash", content) self.assertIn("Receipt previous ledger hash", content) - self.assertIn("Exported current ledger hash", content) + self.assertIn("Exported receipt current ledger hash", content) + self.assertIn("Chain integrity verified: chain anchor -> head is a single, complete path", content) + self.assertIn("Chain integrity verified: root -> head is a single, complete path", content) self.assertNotIn("your_ballot_hash =", content) self.assertNotIn("your_previous_chain_hash =", content) self.assertNotIn("Your Ballot receipt code appears", content) self.assertNotIn("Exported Previous ledger hash", content) self.assertNotIn("Receipt Previous ledger hash", content) + self.assertNotIn("Chain integrity verified: genesis → head is a single, complete path", content) diff --git a/astra_app/core/tokens.py b/astra_app/core/tokens.py index 122942dc..57d0347d 100644 --- a/astra_app/core/tokens.py +++ b/astra_app/core/tokens.py @@ -135,6 +135,14 @@ def election_genesis_chain_hash(election_id: int) -> str: return hashlib.sha256(data).hexdigest() +def election_chain_anchor_hash(*, election_id: int, config_manifest_sha256: str) -> str: + data = ( + f"election-v2:{election_id}:{config_manifest_sha256}. " + "alex estuvo aquí, dejándose el alma." + ).encode() + return hashlib.sha256(data).hexdigest() + + def election_chain_next_hash(*, previous_chain_hash: str, ballot_hash: str) -> str: """ Compute the next chain hash by linking the ballot to the previous chain. diff --git a/astra_app/core/urls.py b/astra_app/core/urls.py index 9c8535d9..42b5f0c4 100644 --- a/astra_app/core/urls.py +++ b/astra_app/core/urls.py @@ -76,6 +76,7 @@ name="elections-turnout-report", ), path("elections/algorithm/", views_elections.election_algorithm, name="election-algorithm"), + path("elections/verify-audit-log.py", views_elections.verify_audit_log_download, name="verify-audit-log-download"), path("elections/ballot/verify/", views_elections.ballot_verify, name="ballot-verify"), path("elections/ballot/verify.json", views_elections.ballot_verify_api, name="ballot-verify-api"), path("elections//edit/", views_elections.election_edit, name="election-edit"), diff --git a/astra_app/core/views_elections/__init__.py b/astra_app/core/views_elections/__init__.py index 9d71b00c..dc994987 100644 --- a/astra_app/core/views_elections/__init__.py +++ b/astra_app/core/views_elections/__init__.py @@ -18,6 +18,7 @@ election_detail_page_api, elections_api, elections_list, + verify_audit_log_download, ) from core.views_elections.edit import election_edit from core.views_elections.lifecycle import ( @@ -58,4 +59,5 @@ "elections_turnout_report", "elections_turnout_report_detail_api", "elections_list", + "verify_audit_log_download", ] diff --git a/astra_app/core/views_elections/audit.py b/astra_app/core/views_elections/audit.py index 1db26499..11fec887 100644 --- a/astra_app/core/views_elections/audit.py +++ b/astra_app/core/views_elections/audit.py @@ -15,6 +15,10 @@ from core import elections_services from core.api_pagination import paginate_detail_items, serialize_pagination +from core.election_chain import ( + CHAIN_VERSION_CONFIG_ANCHOR_V2, + resolve_public_genesis_hash, +) from core.elections_sankey import build_sankey_flows from core.elections_services import candidate_username_by_id_map from core.models import AuditLogEntry, Ballot, Candidate, Election @@ -100,6 +104,26 @@ def _serialize_round_rows_for_api(rows: object) -> list[dict[str, object]]: return serialized +def _serialize_start_candidates_for_api(payload: dict[str, object]) -> list[dict[str, object]]: + raw_candidates = payload.get("candidates") + if not isinstance(raw_candidates, list): + config_manifest = payload.get("config_manifest") + if isinstance(config_manifest, dict): + raw_candidates = config_manifest.get("candidates") + if not isinstance(raw_candidates, list): + return [] + + return [ + { + "id": candidate.get("id"), + "freeipa_username": candidate.get("freeipa_username"), + "tiebreak_uuid": candidate.get("tiebreak_uuid"), + } + for candidate in raw_candidates + if isinstance(candidate, dict) + ] + + def _serialize_audit_log_payload_for_api( *, event_type: str, @@ -130,19 +154,19 @@ def _serialize_audit_log_payload_for_api( return {key: payload[key] for key in allowed_keys if key in payload} case "election_started": serialized: dict[str, object] = {} - if "genesis_chain_hash" in payload: - serialized["genesis_chain_hash"] = payload["genesis_chain_hash"] - candidates = payload.get("candidates") - if isinstance(candidates, list): - serialized["candidates"] = [ - { - "id": candidate.get("id"), - "freeipa_username": candidate.get("freeipa_username"), - "tiebreak_uuid": candidate.get("tiebreak_uuid"), - } - for candidate in candidates - if isinstance(candidate, dict) - ] + chain_version = int(payload.get("chain_version") or 1) + genesis_hash = resolve_public_genesis_hash(payload=payload) + if chain_version == CHAIN_VERSION_CONFIG_ANCHOR_V2: + serialized["chain_version"] = CHAIN_VERSION_CONFIG_ANCHOR_V2 + if "config_manifest_version" in payload: + serialized["config_manifest_version"] = payload["config_manifest_version"] + if "config_manifest_sha256" in payload: + serialized["config_manifest_sha256"] = payload["config_manifest_sha256"] + if genesis_hash: + serialized["genesis_hash"] = genesis_hash + candidates = _serialize_start_candidates_for_api(payload) + if candidates: + serialized["candidates"] = candidates return serialized case "election_closed": serialized = {"chain_head": payload["chain_head"]} if "chain_head" in payload else {} diff --git a/astra_app/core/views_elections/ballot_verify.py b/astra_app/core/views_elections/ballot_verify.py index fb574c82..4c05dc7b 100644 --- a/astra_app/core/views_elections/ballot_verify.py +++ b/astra_app/core/views_elections/ballot_verify.py @@ -1,5 +1,6 @@ """Ballot verification view.""" +import datetime import re from typing import Any @@ -9,6 +10,7 @@ from django.urls import reverse from django.views.decorators.http import require_GET +from core.election_chain import election_genesis_hash from core.models import Ballot, Candidate, Election from core.rate_limit import allow_request @@ -42,6 +44,11 @@ def _ballot_verify_context(request) -> tuple[dict[str, Any], int]: "is_superseded": False, "is_final_ballot": False, "public_ballots_url": "", + "public_audit_url": "", + "publication_bundle": None, + "chain_version": 0, + "config_manifest_sha256": "", + "genesis_hash": "", "rate_limited": True, "verification_snippet": "", }, @@ -58,6 +65,9 @@ def _ballot_verify_context(request) -> tuple[dict[str, Any], int]: "created_at", "is_counted", "superseded_by_id", + "election__chain_anchor_hash", + "election__chain_version", + "election__config_manifest_sha256", "election__id", "election__name", "election__status", @@ -79,10 +89,22 @@ def _ballot_verify_context(request) -> tuple[dict[str, Any], int]: has_public_verification = election is not None and election.status in {Election.Status.closed, Election.Status.tallied} public_ballots_url = "" + public_audit_url = "" + publication_bundle: dict[str, str] | None = None if election is not None and has_public_verification: public_ballots_url = election.public_ballots_file.url if election.public_ballots_file else reverse( "election-public-ballots", args=[election.id] ) + public_audit_url = election.public_audit_file.url if election.public_audit_file else reverse( + "election-public-audit", args=[election.id] + ) + if election.artifacts_generated_at is not None: + publication_bundle = { + "published_at": election.artifacts_generated_at.astimezone(datetime.UTC) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + } verification_snippet = "" if found and election is not None and ballot is not None: @@ -114,6 +136,11 @@ def _ballot_verify_context(request) -> tuple[dict[str, Any], int]: "is_superseded": is_superseded, "is_final_ballot": is_final_ballot, "public_ballots_url": public_ballots_url, + "public_audit_url": public_audit_url, + "publication_bundle": publication_bundle, + "chain_version": int(election.chain_version) if election is not None else 0, + "config_manifest_sha256": str(election.config_manifest_sha256 or "") if election is not None else "", + "genesis_hash": election_genesis_hash(election=election) if election is not None else "", "rate_limited": False, "verification_snippet": verification_snippet, }, @@ -160,6 +187,11 @@ def ballot_verify_api(request): "is_superseded": context["is_superseded"], "is_final_ballot": context["is_final_ballot"], "public_ballots_url": context["public_ballots_url"], + "public_audit_url": context["public_audit_url"], + "publication_bundle": context["publication_bundle"], + "chain_version": context["chain_version"], + "config_manifest_sha256": context["config_manifest_sha256"], + "genesis_hash": context["genesis_hash"], "rate_limited": context["rate_limited"], "verification_snippet": context["verification_snippet"], }, diff --git a/astra_app/core/views_elections/detail.py b/astra_app/core/views_elections/detail.py index a2574bbe..31b2f5ac 100644 --- a/astra_app/core/views_elections/detail.py +++ b/astra_app/core/views_elections/detail.py @@ -1,13 +1,15 @@ """Election listing, detail view, and voter eligibility context.""" import datetime +import re +from pathlib import Path from typing import Any, cast from django.conf import settings from django.contrib import messages from django.db.models import Count, Prefetch from django.db.models.functions import TruncDate -from django.http import Http404, HttpRequest, JsonResponse +from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render from django.urls import reverse from django.utils import timezone @@ -19,6 +21,7 @@ from core.election_nominators import parse_nominator_identifier from core.elections_eligibility import ElectionEligibilityError from core.elections_services import candidate_username_by_id_map, election_quorum_status +from core.elections_timestamping import _load_private_key, rekor_signing_public_key_material from core.models import ( AuditLogEntry, Candidate, @@ -39,12 +42,54 @@ from core.views_groups import _serialize_group_user_list_items from core.views_utils import build_page_url_prefix, get_username, paginate_and_build_context +VERIFY_AUDIT_LOG_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "static" / "verify-audit-log.py" +VERIFY_AUDIT_LOG_TRUSTED_PEM_RE = re.compile( + r'trusted_public_key_pem: str = """.*?"""', + re.DOTALL, +) +VERIFY_AUDIT_LOG_TRUSTED_FINGERPRINT_RE = re.compile( + r'trusted_public_key_sha256: str = "[^"]*"', +) + @require_GET def election_algorithm(request): return render(request, "core/election_algorithm.html", {}) +@require_GET +def verify_audit_log_download(_request: HttpRequest) -> HttpResponse: + script_content = VERIFY_AUDIT_LOG_SCRIPT_PATH.read_text(encoding="utf-8") + + try: + private_key = _load_private_key() + trusted_public_key_pem_bytes, trusted_public_key_sha256 = rekor_signing_public_key_material( + private_key=private_key + ) + except (OSError, TypeError, ValueError): + return HttpResponse( + "Audit verifier download is temporarily unavailable.\n", + content_type="text/plain; charset=utf-8", + status=503, + ) + else: + trusted_public_key_pem = trusted_public_key_pem_bytes.decode("utf-8").strip() + rendered_script = VERIFY_AUDIT_LOG_TRUSTED_PEM_RE.sub( + f'trusted_public_key_pem: str = """{trusted_public_key_pem}"""', + script_content, + count=1, + ) + rendered_script = VERIFY_AUDIT_LOG_TRUSTED_FINGERPRINT_RE.sub( + f'trusted_public_key_sha256: str = "{trusted_public_key_sha256}"', + rendered_script, + count=1, + ) + + response = HttpResponse(rendered_script, content_type="text/x-python; charset=utf-8") + response["Content-Disposition"] = 'attachment; filename="verify-audit-log.py"' + return response + + def _can_manage_elections(request: HttpRequest) -> bool: return request.user.has_perm(ASTRA_ADD_ELECTION) diff --git a/astra_app/core/views_elections/edit.py b/astra_app/core/views_elections/edit.py index 44241d22..02f5ef00 100644 --- a/astra_app/core/views_elections/edit.py +++ b/astra_app/core/views_elections/edit.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required +from django.core.exceptions import ValidationError from django.db import transaction from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect, render @@ -12,13 +13,16 @@ from core import elections_eligibility, elections_services from core import signals as astra_signals +from core.election_chain import ( + CONFIG_MANIFEST_VERSION, + build_config_manifest, + config_manifest_sha256, + load_locked_manifest_source_rows, +) from core.election_nominators import parse_nominator_identifier from core.elections_eligibility import ElectionEligibilityError -from core.elections_services import ( - election_genesis_chain_hash, - issue_credentials_at_start_transition, -) -from core.elections_timestamping import schedule_attestation +from core.elections_services import issue_credentials_at_start_transition +from core.elections_timestamping import ElectionAttestationError, attest_entry_or_raise, schedule_attestation from core.forms_elections import ( CandidateWizardFormSet, ElectionDetailsForm, @@ -39,6 +43,7 @@ ) from core.permissions import ASTRA_ADD_ELECTION from core.templated_email import placeholderize_empty_values, render_templated_email_preview +from core.tokens import election_chain_anchor_hash, election_genesis_chain_hash from core.user_labels import user_choice_from_freeipa from core.views_elections._helpers import ( _election_email_preview_context, @@ -47,6 +52,35 @@ ) from core.views_utils import get_username +_DRAFT_LOCKED_MESSAGE = "This election is no longer in draft; draft changes are locked." + + +def _lock_draft_election_for_save(*, election_id: int) -> Election: + locked_election = Election.objects.select_for_update().get(pk=election_id) + if locked_election.status != Election.Status.draft: + raise ValidationError(_DRAFT_LOCKED_MESSAGE) + return locked_election + + +def _copy_draft_editable_fields(*, source: Election, target: Election) -> None: + target.name = source.name + target.description = source.description + target.url = source.url + target.eligible_group_cn = source.eligible_group_cn + target.start_datetime = source.start_datetime + target.end_datetime = source.end_datetime + target.number_of_seats = source.number_of_seats + target.quorum = source.quorum + + +def _replace_group_memberships(*, group: ExclusionGroup, selected_candidates: list[Candidate]) -> None: + _lock_draft_election_for_save(election_id=group.election_id) + memberships = list(ExclusionGroupCandidate.objects.filter(exclusion_group=group)) + for membership in memberships: + membership.delete() + for candidate in selected_candidates: + ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=candidate) + def _configure_candidate_choices( request: HttpRequest, @@ -203,53 +237,57 @@ def _save_candidates_and_groups( group_formset: ExclusionGroupWizardFormSet, ) -> None: """Persist candidate and exclusion-group formsets to the database.""" - for form in candidate_formset.forms: - if not hasattr(form, "cleaned_data"): - continue - if form.cleaned_data.get("DELETE"): - if form.instance.pk: - form.instance.delete() - continue + with transaction.atomic(): + locked_election = _lock_draft_election_for_save(election_id=election.pk) - username = str(form.cleaned_data.get("freeipa_username") or "").strip() - if not username: - continue + for form in candidate_formset.forms: + if not hasattr(form, "cleaned_data"): + continue + if form.cleaned_data.get("DELETE"): + if form.instance.pk: + form.instance.delete() + continue - candidate = form.save(commit=False) - candidate.election = election - candidate.save() + username = str(form.cleaned_data.get("freeipa_username") or "").strip() + if not username: + continue - for form in group_formset.forms: - if not hasattr(form, "cleaned_data"): - continue - if form.cleaned_data.get("DELETE"): - if form.instance.pk: - form.instance.delete() - continue + candidate = form.save(commit=False) + candidate.election = locked_election + candidate.save() - group_name = str(form.cleaned_data.get("name") or "").strip() - if not group_name: - continue + for form in group_formset.forms: + if not hasattr(form, "cleaned_data"): + continue + if form.cleaned_data.get("DELETE"): + if form.instance.pk: + form.instance.delete() + continue - group = form.save(commit=False) - group.election = election - group.save() + group_name = str(form.cleaned_data.get("name") or "").strip() + if not group_name: + continue - selected_usernames = [ - str(u).strip() for u in (form.cleaned_data.get("candidate_usernames") or []) - ] - selected_usernames = [u for u in selected_usernames if u] - candidates = list( - Candidate.objects.filter(election=election, freeipa_username__in=selected_usernames).only("id") - ) - by_username = {c.freeipa_username: c for c in candidates} + group = form.save(commit=False) + group.election = locked_election + group.save() + + selected_usernames = [ + str(u).strip() for u in (form.cleaned_data.get("candidate_usernames") or []) + ] + selected_usernames = [u for u in selected_usernames if u] + candidates = list( + Candidate.objects.filter( + election=locked_election, + freeipa_username__in=selected_usernames, + ).only("id", "freeipa_username") + ) + by_username = {candidate.freeipa_username: candidate for candidate in candidates} + ordered_candidates = [ + candidate for username in selected_usernames if (candidate := by_username.get(username)) is not None + ] - ExclusionGroupCandidate.objects.filter(exclusion_group=group).delete() - for u in selected_usernames: - c = by_username.get(u) - if c is None: - continue - ExclusionGroupCandidate.objects.create(exclusion_group=group, candidate=c) + _replace_group_memberships(group=group, selected_candidates=ordered_candidates) def _issue_and_email_credentials( @@ -323,6 +361,9 @@ def _handle_start_election( if election.status != Election.Status.draft: messages.error(request, "Only draft elections can be started.") return None + if not Election.objects.filter(pk=election.pk, status=Election.Status.draft).exists(): + messages.error(request, "This election has already been started.") + return None if not details_form.is_valid() or not email_form.is_valid(): messages.error(request, "Please correct the errors below.") return None @@ -429,71 +470,112 @@ def _handle_start_election( # All validations passed — commit the election start. started_at = timezone.now() - with transaction.atomic(): - locked = Election.objects.select_for_update().get(pk=election.pk) - if locked.status != Election.Status.draft: - messages.error(request, "This election has already been started.") - return None - - # Re-bind details to the locked row so concurrent updates cannot race this write. - locked_form = ElectionDetailsForm(details_form.data, instance=locked) - if not locked_form.is_valid(): - messages.error(request, "Please correct the errors below.") - return None - locked = locked_form.save(commit=False) + try: + with transaction.atomic(): + locked = Election.objects.select_for_update().get(pk=election.pk) + if locked.status != Election.Status.draft: + messages.error(request, "This election has already been started.") + return None + + # Re-bind details to the locked row so concurrent updates cannot race this write. + locked_form = ElectionDetailsForm(details_form.data, instance=locked) + if not locked_form.is_valid(): + messages.error(request, "Please correct the errors below.") + return None + locked = locked_form.save(commit=False) + + # Align the published start timestamp with when the election actually opens. + locked.start_datetime = started_at + _apply_email_template_from_form(locked, email_form) + + candidate_rows, groups, group_candidates = load_locked_manifest_source_rows(election=locked) + payload: dict[str, object] = { + "chain_version": locked.chain_version, + "eligible_voters": len(start_eligible_voters), + } - # Align the published start timestamp with when the election actually opens. - locked.start_datetime = started_at - _apply_email_template_from_form(locked, email_form) + if int(locked.chain_version or 1) == 2: + manifest = build_config_manifest( + election=locked, + candidate_rows=candidate_rows, + groups=groups, + group_candidates=group_candidates, + ) + manifest_digest = config_manifest_sha256(manifest) + locked.config_manifest_version = CONFIG_MANIFEST_VERSION + locked.config_manifest = manifest + locked.config_manifest_sha256 = manifest_digest + locked.chain_anchor_hash = election_chain_anchor_hash( + election_id=locked.id, + config_manifest_sha256=manifest_digest, + ) + payload.update( + { + "config_manifest_version": locked.config_manifest_version, + "config_manifest_sha256": locked.config_manifest_sha256, + "chain_anchor_hash": locked.chain_anchor_hash, + "config_manifest": locked.config_manifest, + } + ) + else: + payload.update( + { + "genesis_chain_hash": election_genesis_chain_hash(locked.id), + "candidates": [ + { + "id": candidate.id, + "freeipa_username": candidate.freeipa_username, + "tiebreak_uuid": str(candidate.tiebreak_uuid), + } + for candidate in candidate_rows + ], + } + ) - locked.status = Election.Status.open - locked.save() + locked.save() - total_credentials, emailed, skipped, failures = _issue_and_email_credentials(request, locked) + username = get_username(request) + if username: + payload["actor"] = username - username = get_username(request) - candidate_snapshot = list( - Candidate.objects.filter(election=locked) - .only("id", "freeipa_username", "tiebreak_uuid") - .order_by("freeipa_username", "id") - ) - payload: dict[str, object] = { - "eligible_voters": total_credentials, - "emailed": emailed, - "skipped": skipped, - "failures": failures, - "genesis_chain_hash": election_genesis_chain_hash(locked.id), - "candidates": [ - { - "id": c.id, - "freeipa_username": c.freeipa_username, - "tiebreak_uuid": str(c.tiebreak_uuid), - } - for c in candidate_snapshot - ], - } - if username: - payload["actor"] = username - - audit_entry = AuditLogEntry.objects.create( - election=locked, - event_type="election_started", - payload=payload, - is_public=True, - ) - schedule_attestation(audit_entry) + audit_entry = AuditLogEntry.objects.create( + election=locked, + event_type="election_started", + payload=payload, + is_public=True, + ) + if int(locked.chain_version or 1) == 2: + attest_entry_or_raise(audit_entry) + else: + schedule_attestation(audit_entry) + + locked.status = Election.Status.open + locked.save(update_fields=["status", "updated_at"]) + + total_credentials, emailed, skipped, failures = _issue_and_email_credentials(request, locked) + audit_entry.payload = { + **payload, + "eligible_voters": total_credentials, + "emailed": emailed, + "skipped": skipped, + "failures": failures, + } + audit_entry.save(update_fields=["payload"]) - opened_election_id = locked.id + opened_election_id = locked.id - def _send_opened_signal() -> None: - committed_election = Election.objects.get(pk=opened_election_id) - astra_signals.election_opened.send( - sender=Election, - election=committed_election, - actor=username, - ) + def _send_opened_signal() -> None: + committed_election = Election.objects.get(pk=opened_election_id) + astra_signals.election_opened.send( + sender=Election, + election=committed_election, + actor=username, + ) - transaction.on_commit(_send_opened_signal) + transaction.on_commit(_send_opened_signal) + except ElectionAttestationError as exc: + messages.error(request, str(exc)) + return None if emailed: messages.success(request, f"Election started; emailed {emailed} voter(s).") @@ -754,16 +836,27 @@ def _membership_eligibility_sets(for_election: Election) -> tuple[set[str], set[ formsets_ok = False if formsets_ok: - election.status = Election.Status.draft - - if election_id == 0 or email_save_mode != "keep_existing": - _apply_email_template_from_form(election, email_form) - - election.save() - _save_candidates_and_groups(election, candidate_formset, group_formset) - - messages.success(request, "Draft saved.") - return redirect("election-edit", election_id=election.id) + try: + with transaction.atomic(): + if election.pk is not None: + locked_election = _lock_draft_election_for_save(election_id=election.pk) + _copy_draft_editable_fields(source=election, target=locked_election) + election = locked_election + + election.status = Election.Status.draft + + if election_id == 0 or email_save_mode != "keep_existing": + _apply_email_template_from_form(election, email_form) + + election.save() + _save_candidates_and_groups(election, candidate_formset, group_formset) + except ValidationError as exc: + for message in exc.messages: + messages.error(request, message) + formsets_ok = False + else: + messages.success(request, "Draft saved.") + return redirect("election-edit", election_id=election.id) if action == "start_election": result = _handle_start_election(request, election, details_form, email_form) diff --git a/astra_app/core/views_elections/vote.py b/astra_app/core/views_elections/vote.py index c0824227..0337119b 100644 --- a/astra_app/core/views_elections/vote.py +++ b/astra_app/core/views_elections/vote.py @@ -263,6 +263,8 @@ def election_vote_submit(request, election_id: int): "election_id": election.id, "email_queued": email_queued, "ballot_hash": receipt.ballot.ballot_hash, + "chain_version": int(election.chain_version or 1), + "config_manifest_sha256": str(election.config_manifest_sha256 or ""), "nonce": receipt.nonce, "previous_chain_hash": receipt.ballot.previous_chain_hash, "chain_hash": receipt.ballot.chain_hash, diff --git a/docs/runbooks/meek-stv-elections.md b/docs/runbooks/meek-stv-elections.md index 70405479..9b502487 100644 --- a/docs/runbooks/meek-stv-elections.md +++ b/docs/runbooks/meek-stv-elections.md @@ -24,7 +24,7 @@ Starting an election is a `start_election` action from the edit flow. On start, - Sets `start_datetime` to `timezone.now()`. - Sets `status = open`. - Issues and emails voting credentials. -- Writes a public `election_started` audit event that includes `genesis_chain_hash`.[^fn7] +- Writes a public `election_started` audit event. The stored version-specific payload still uses `genesis_chain_hash` for `chain_version = 1` and `chain_anchor_hash` for `chain_version = 2`, while public/operator-facing readers re-expose the first chain hash as `genesis_hash`.[^fn7] Voting routes are available at `/elections//vote/` and `/elections//vote/submit.json`. The vote page returns a 410 closed template for `closed`/`tallied` elections.[^fn8][^fn9] @@ -89,7 +89,7 @@ Ballot receipt hash input includes `(election_id, credential_public_id, ranking, `nonce` is random (`token_hex(16)`), used in hash generation, and intentionally not stored.[^fn34] -Ballot chaining uses previous chain head (or election genesis) and stores both `previous_chain_hash` and `chain_hash` on ballot rows.[^fn35] +Ballot chaining uses the previous chain head and stores both `previous_chain_hash` and `chain_hash` on ballot rows. The initial chain origin is versioned: `chain_version = 1` starts from the election genesis hash, while `chain_version = 2` starts from the manifest-derived anchor that public readers expose as `genesis_hash`.[^fn35] If a credential re-votes, Astra supersedes prior final ballot by pointer flips so only one final counted ballot remains for that credential.[^fn36][^fn37] @@ -123,22 +123,24 @@ The post-close model keeps ballots, audit data, and credential rows, but usernam ## 5. Ballot Chain Integrity -Genesis chain hash is election-specific, preventing cross-election chain splicing from a shared genesis. The exact preimage string hashed (UTF-8) is: +For `chain_version = 1`, the genesis chain hash is election-specific, preventing cross-election chain splicing from a shared genesis. The exact preimage string hashed (UTF-8) is: - `f"election:{election_id}. alex estuvo aquí, dejándose el alma."` and the genesis hash is `sha256(preimage_bytes).hexdigest()`.[^fn41] +For both `chain_version = 1` and `chain_version = 2`, top-level public export metadata publishes one canonical chain-start field: `genesis_hash`. For `chain_version = 1`, `genesis_hash` is the election-specific genesis hash. For `chain_version = 2`, `genesis_hash` carries the manifest-derived chain anchor value. Version-specific event payload semantics remain separate: `election_started` uses `genesis_chain_hash` for v1 payloads and `chain_anchor_hash` for v2 payloads. Legacy top-level `chain_root_hash` and `chain_anchor_hash` values remain compatibility-only inputs for historical bundles.[^fn41] + Next chain hash is `sha256(f"{previous_chain_hash}:{ballot_hash}")`.[^fn42] -`public-ballots.json` includes per-ballot `ballot_hash`, `previous_chain_hash`, `chain_hash`, `is_counted`, `superseded_by`, and export-level `chain_head`.[^fn43] +`public-ballots.json` includes per-ballot `ballot_hash`, `previous_chain_hash`, `chain_hash`, `is_counted`, `superseded_by`, and export-level `chain_head`. For both chain versions, the top-level origin field is `genesis_hash`. For `chain_version = 2`, the export also publishes `config_manifest_version` and `config_manifest_sha256`; new exports do not publish duplicate top-level `chain_root_hash` or `chain_anchor_hash` values.[^fn43] At close, Astra also records `chain_head` in public close event payload.[^fn44] The published verification scripts implement local checks: - `verify-ballot-hash.py`: recomputes ballot hash from receipt inputs with the same payload and SHA-256 method as model code.[^fn45] -- `verify-ballot-chain.py`: reconstructs chain from genesis, verifies per-row hash links, and fails on fork, cycle, disconnected graph, missing genesis linkage, or head mismatch.[^fn46] +- `verify-ballot-chain.py`: reconstructs the chain from the versioned origin carried by top-level `genesis_hash`, verifies per-row hash links, and fails on fork, cycle, disconnected graph, missing origin linkage, or head mismatch. The verifier accepts historical top-level aliases (`chain_root_hash` and, for v2 bundles, `chain_anchor_hash`) as deprecated compatibility inputs.[^fn46] - `verify-audit-log.py`: verifies Rekor transparency-log attestations in `public-audit.json`, recomputing canonical event digests and (optionally) querying each Rekor entry URL online.[^fn103] ## 6. Quorum Rules @@ -326,7 +328,7 @@ Astra's security properties depend on who is trusted and to what degree. - **Tally integrity:** The Meek STV count is deterministic given the published ballot set, candidate tie-break UUIDs, and convergence parameters. Tally round artifacts in `public-audit.json` document every round. - **Inclusion and chain-position verifiability:** Each voter receives `ballot_hash`, `previous_chain_hash`, and `chain_hash` in their receipt email. They can independently recompute their ballot hash from their ranking and nonce, verify the chain update formula, and confirm both that their ballot appears in `public-ballots.json` and that the chain hash at their position matches. Any tampering with an intermediate ballot would conflict with that voter's receipt and cascade to all subsequent voters' `previous_chain_hash` values. -- **Chain integrity:** The full ballot hash chain from genesis to chain head is independently verifiable using the published `verify-ballot-chain.py` script. The `election_started` payload includes the chain origin (`genesis_chain_hash`), and the `election_closed` payload includes the chain terminus (`chain_head`). If Astra is configured to use [Sigstore's Rekor](https://docs.sigstore.dev/logging/overview/) (intended default) and Rekor is working, Astra will attempt to attest those events (and other critical events) to Rekor; if Rekor is not configured or attestation fails, the events are still present in `public-audit.json` but may not have Rekor metadata. +- **Chain integrity:** The full ballot hash chain from genesis hash to chain head is independently verifiable using the published `verify-ballot-chain.py` script. For `chain_version = 1`, `genesis_hash` is the election genesis hash; for `chain_version = 2`, `genesis_hash` is the manifest-derived chain anchor. The attested `election_started` payload still carries the version-specific internal field names. If Astra is configured to use [Sigstore's Rekor](https://docs.sigstore.dev/logging/overview/) (intended default) and Rekor is working, Astra will attempt to attest those events (and other critical events) to Rekor; if Rekor is not configured or attestation fails, the events are still present in `public-audit.json` but may not have Rekor metadata. - **Post-election auditability:** Public artifacts (`public-ballots.json`, `public-audit.json`) are generated at tally and allow third-party review of the count, ballot set, and anonymized voter weights. - **Pseudonymization at close:** Direct username-to-ballot linkage is removed at close time. @@ -363,7 +365,7 @@ Ballots in the published export appear in submission order (not necessarily iden - The `public-ballots.json` export includes **all** ballots—including superseded (re-voted) and un-counted ones—marked via the `is_counted` field. The chain is not limited to final counted ballots.[^fn43] - The ballot-verify endpoint intentionally does not reveal ranking, voter IP addresses, or precise timestamps; it exposes only a submission date. This is a deliberate privacy guardrail.[^fn71] - Rate-limit scoping: vote submission is scoped per (election, username); ballot verification is scoped per client IP address.[^fn50][^fn71] -- Empty-election chain head: if no ballots are cast, the chain head equals the election genesis hash. Independent verifiers should account for this case when no ballot rows are present.[^fn41] +- Empty-election chain head: if no ballots are cast, the chain head equals the versioned genesis hash. For `chain_version = 1` that is the election genesis hash; for `chain_version = 2` that is the manifest-derived chain anchor exposed publicly as `genesis_hash`. Independent verifiers should account for this case when no ballot rows are present.[^fn41] - Quorum and closure: the election model's `quorum` help text implies quorum affects whether an election can be concluded, but `close_election` does not block when quorum is unmet. Quorum is informational/tracked, and extension is a separate explicit action. Auditors should verify participation independently via the `public-audit.json` `quorum_reached` event (or its absence).[^fn49][^fn51] [^fn1]: https://github.com/AlmaLinux/astra/blob/bd07b766/astra_app/core/elections_services.py#L39 diff --git a/frontend/src/ballot-verify/BallotVerifyPage.vue b/frontend/src/ballot-verify/BallotVerifyPage.vue index 7ed340ff..57791f5d 100644 --- a/frontend/src/ballot-verify/BallotVerifyPage.vue +++ b/frontend/src/ballot-verify/BallotVerifyPage.vue @@ -107,8 +107,9 @@ onBeforeUnmount(() => { Local verification scripts
    @@ -163,12 +164,39 @@ onBeforeUnmount(() => {

    This ballot was included in the final tally.

    -