From 0a4e06077de9dd7eb72a6df50f604abf9d65263c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 03:41:01 +0200 Subject: [PATCH] feat: add confidential ballot reference provider --- README.md | 7 +- docs/VOTING_DOMAIN.md | 32 + src/govoplan_voting/backend/db/models.py | 119 +++ .../backend/local_confidential_provider.py | 761 ++++++++++++++++++ src/govoplan_voting/backend/manifest.py | 51 +- ...e2f3a_v0115_local_confidential_provider.py | 142 ++++ src/govoplan_voting/backend/service.py | 204 ++++- tests/test_migrations.py | 4 +- tests/test_voting.py | 193 ++++- .../features/voting/VotingBallotDialog.tsx | 36 +- 10 files changed, 1505 insertions(+), 44 deletions(-) create mode 100644 src/govoplan_voting/backend/local_confidential_provider.py create mode 100644 src/govoplan_voting/backend/migrations/versions/8b9c0d1e2f3a_v0115_local_confidential_provider.py diff --git a/README.md b/README.md index ab18d35..4feb6c7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,11 @@ The native `recorded` assurance profile is auditable but not secret: active ballots are stored so that a result can be reconstructed. Confidential, secret, and externally certified profiles fail closed unless an explicit `voting.provider.` capability is installed. Providers return only -aggregate results and evidence; raw secret ballots and provider credentials -do not enter the Voting database. +aggregate results and evidence through the public Voting boundary. The bundled +`local_confidential` provider stores cast selections only as Encryption-owned +server-envelope ciphertext in provider-specific shared SQL tables. It is +confidential at rest, but it is not anonymous, secret, coercion-resistant, or +externally certified. Committee and other modules retain their deliberation or business context and refer to Voting ballots through the `voting.ballots` capability. Informal diff --git a/docs/VOTING_DOMAIN.md b/docs/VOTING_DOMAIN.md index c6f243c..f445c85 100644 --- a/docs/VOTING_DOMAIN.md +++ b/docs/VOTING_DOMAIN.md @@ -32,6 +32,30 @@ credentials inside its own assurance boundary and returns aggregate counts, weighted counts, a result hash, and evidence. GovOPlaN does not claim that a provider or deployment satisfies legal or certification requirements merely because the adapter contract is implemented. +Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields +that can carry credentials, private key material, plaintext, or raw +selections before Voting or Committee can persist the projection. + +### Bundled local confidential provider + +`local_confidential` is an interactive reference provider for the +`confidential` profile. Opening a ballot creates a ballot-scoped Encryption +vault using the configured `local_aesgcm` provider. Casting stores the elector, +weight, receipt, and replacement generation as provider metadata while the +selection payload is stored only as authenticated ciphertext. Finalization +opens active cast envelopes inside the provider boundary and exports only the +aggregate and sanitized evidence. + +Encryption records a context-bound keyed commitment rather than an enumerable +plaintext digest, so low-entropy choices are not exposed to an offline database +reader through idempotency metadata. + +This profile is server-readable. It does not provide voter anonymity, +unlinkability, coercion resistance, client-held keys, an HSM/KMS claim, or +external certification. It therefore cannot be selected for `secret` or +`external_certified` ballots. A provider ballot reference is generated when +the ballot opens; externally hosted providers may continue to require a +pre-existing reference. ## Lifecycle and concurrency @@ -55,6 +79,8 @@ Back up these tables as one consistency unit: - `voting_cast_records` - `voting_lifecycle_events` - `voting_command_replays` +- `voting_confidential_ballots` +- `voting_confidential_casts` A restore is valid only when current revisions are unique per tenant and ballot, cast generations are unique per elector, every active cast references @@ -62,6 +88,10 @@ the current frozen definition hash, result hashes can be recomputed, and event sequences are contiguous. Provider-backed restores also require the external provider evidence and ballot reference to remain resolvable. Do not reopen a restored external ballot merely to compensate for missing provider state. +For `local_confidential`, restore the Voting and Encryption tables from the +same consistency point and restore the exact deployment `MASTER_KEY_B64` from +the approved secret store. A database-only restore or a changed/lost master +key must fail closed; it cannot reconstruct confidential casts. Before destructive retirement, stop new casts, retain a verified database snapshot, export result/certification evidence under the applicable retention @@ -74,6 +104,8 @@ node-local filesystem. - tenant predicates apply to every ballot, cast, event, and replay lookup - the authenticated account must match the frozen elector subject - native casting fails closed for non-recorded assurance profiles +- interactive provider casting validates the frozen tenant, electorate, + definition, options, replacement policy, and time window again - raw selections are never returned by list, detail, result, or history APIs - provider result keys must exactly match frozen options - external results require evidence and cannot exceed the frozen electorate diff --git a/src/govoplan_voting/backend/db/models.py b/src/govoplan_voting/backend/db/models.py index 395a110..5bac056 100644 --- a/src/govoplan_voting/backend/db/models.py +++ b/src/govoplan_voting/backend/db/models.py @@ -5,11 +5,13 @@ from typing import Any import uuid from sqlalchemy import ( + Boolean, DateTime, ForeignKey, Index, Integer, JSON, + LargeBinary, String, UniqueConstraint, ) @@ -145,9 +147,126 @@ class VotingCommandReplay(Base, TimestampMixin): response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) +class VotingConfidentialBallot(Base, TimestampMixin): + __tablename__ = "voting_confidential_ballots" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "provider_ballot_ref", + name="uq_voting_confidential_provider_ref", + ), + UniqueConstraint( + "tenant_id", + "ballot_id", + name="uq_voting_confidential_ballot", + ), + Index( + "ix_voting_confidential_ballot_state", + "tenant_id", + "state", + "prepared_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + provider_ballot_ref: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + electorate_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + assurance_profile: Mapped[str] = mapped_column(String(40), nullable=False) + method: Mapped[str] = mapped_column(String(40), nullable=False) + state: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + options: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False) + electorate: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False) + allow_replacement: Mapped[bool] = mapped_column(Boolean, nullable=False) + quorum_weight: Mapped[int] = mapped_column(Integer, nullable=False) + threshold_numerator: Mapped[int] = mapped_column(Integer, nullable=False) + threshold_denominator: Mapped[int] = mapped_column(Integer, nullable=False) + opens_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + closes_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + vault_id: Mapped[str] = mapped_column(String(255), nullable=False) + preparation_idempotency_key: Mapped[str] = mapped_column( + String(160), nullable=False + ) + preparation_request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + prepared_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + finalization_idempotency_key: Mapped[str | None] = mapped_column( + String(160), nullable=True + ) + finalized_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + result: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + + +class VotingConfidentialCast(Base, TimestampMixin): + __tablename__ = "voting_confidential_casts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "provider_ballot_id", + "elector_id", + "generation", + name="uq_voting_confidential_cast_generation", + ), + UniqueConstraint( + "tenant_id", + "provider_ballot_id", + "idempotency_key", + name="uq_voting_confidential_cast_idempotency", + ), + Index( + "ix_voting_confidential_cast_active", + "tenant_id", + "provider_ballot_id", + "elector_id", + "superseded_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + provider_ballot_id: Mapped[str] = mapped_column( + ForeignKey("voting_confidential_ballots.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + elector_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + generation: Mapped[int] = mapped_column(Integer, nullable=False) + definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + encryption_envelope_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + encryption_resource_id: Mapped[str] = mapped_column( + String(36), nullable=False, index=True + ) + weight: Mapped[int] = mapped_column(Integer, nullable=False) + cast_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + superseded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + + __all__ = [ "VotingBallotRevision", "VotingCastRecord", "VotingCommandReplay", + "VotingConfidentialBallot", + "VotingConfidentialCast", "VotingLifecycleEvent", ] diff --git a/src/govoplan_voting/backend/local_confidential_provider.py b/src/govoplan_voting/backend/local_confidential_provider.py new file mode 100644 index 0000000..8f3a217 --- /dev/null +++ b/src/govoplan_voting/backend/local_confidential_provider.py @@ -0,0 +1,761 @@ +from __future__ import annotations + +from dataclasses import asdict +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy.orm import Session + +from govoplan_core.core.encryption import ( + ContentProtectionRequest, + ContentUnprotectionRequest, + KeyVaultCreateRequest, + encryption_content_cipher, + encryption_key_vault, +) +from govoplan_core.core.voting import ( + ExternalVotingBallotRef, + ExternalVotingCastRequest, + ExternalVotingFinalizationRequest, + ExternalVotingPreparationRequest, + VotingReceipt, + VotingResult, +) +from govoplan_voting.backend.db.models import ( + VotingConfidentialBallot, + VotingConfidentialCast, +) + + +LOCAL_CONFIDENTIAL_PROVIDER_ID = "local_confidential" +LOCAL_KEY_PROVIDER_ID = "local_aesgcm" + + +class LocalConfidentialVotingError(ValueError): + pass + + +class LocalConfidentialVotingProvider: + """Reference confidential provider backed by encrypted shared SQL state. + + The provider conceals selections at rest from ordinary Voting storage. It is + server-decryptable and intentionally makes no secret-ballot, coercion- + resistance, anonymity, HSM, or external-certification claim. + """ + + def __init__(self, registry: object | None) -> None: + self._registry = registry + + def prepare_ballot( + self, + session: object, + principal: object, + *, + request: ExternalVotingPreparationRequest, + ) -> ExternalVotingBallotRef: + db = _session(session) + _require_tenant(principal, request.tenant_id) + if request.assurance_profile != "confidential": + raise LocalConfidentialVotingError( + "The local confidential provider supports only the confidential assurance profile." + ) + provider_ref = ( + str(request.requested_provider_ballot_ref or "").strip() + or f"local-confidential:{request.ballot_id}" + ) + payload = _preparation_payload(request, provider_ref=provider_ref) + digest = _digest(payload) + existing = ( + db.query(VotingConfidentialBallot) + .filter( + VotingConfidentialBallot.tenant_id == request.tenant_id, + VotingConfidentialBallot.provider_ballot_ref == provider_ref, + ) + .one_or_none() + ) + if existing is not None: + if existing.preparation_request_sha256 != digest: + raise LocalConfidentialVotingError( + "The confidential provider ballot reference was reused for another definition." + ) + return self._ballot_ref(existing, replayed=True) + + cipher = encryption_content_cipher(self._registry) + key_vault = encryption_key_vault(self._registry) + if cipher is None or key_vault is None: + raise LocalConfidentialVotingError( + "The local confidential provider requires the Encryption key-vault and content-cipher capabilities." + ) + vault_id = f"voting-{request.ballot_id}" + vault = key_vault.get_vault( + db, + tenant_id=request.tenant_id, + vault_id=vault_id, + ) + if vault is None: + vault = key_vault.create_vault( + db, + principal, + request=KeyVaultCreateRequest( + tenant_id=request.tenant_id, + vault_id=vault_id, + name=f"Confidential ballot {request.ballot_id}", + provider_id=LOCAL_KEY_PROVIDER_ID, + purpose="voting.confidential.ballot", + algorithm_suite="AES-256-GCM", + scope_type="voting_ballot", + scope_id=request.ballot_id, + policy_ref="voting:local-confidential:v1", + idempotency_key=f"voting-vault:{request.ballot_id}", + recovery_quorum=2, + profile_kind="server_envelope", + ), + ) + if vault.state != "active" or vault.current_key is None: + raise LocalConfidentialVotingError( + "The confidential ballot Encryption vault is not active." + ) + row = VotingConfidentialBallot( + tenant_id=request.tenant_id, + provider_ballot_ref=provider_ref, + ballot_id=request.ballot_id, + definition_sha256=request.definition_sha256, + electorate_sha256=request.electorate_sha256, + assurance_profile=request.assurance_profile, + method=request.method, + state="open", + options=[asdict(item) for item in request.options], + electorate=[ + { + "subject_id": item.subject_id, + "label": item.label, + "weight": item.weight, + "provenance": dict(item.provenance), + } + for item in request.electorate + ], + allow_replacement=request.allow_replacement, + quorum_weight=request.quorum_weight, + threshold_numerator=request.threshold_numerator, + threshold_denominator=request.threshold_denominator, + opens_at=request.opens_at, + closes_at=request.closes_at, + vault_id=vault_id, + preparation_idempotency_key=request.idempotency_key, + preparation_request_sha256=digest, + prepared_at=request.requested_at, + ) + db.add(row) + db.flush() + return self._ballot_ref(row) + + def cast_ballot( + self, + session: object, + principal: object, + *, + request: ExternalVotingCastRequest, + ) -> VotingReceipt: + db = _session(session) + _require_tenant(principal, request.tenant_id) + actor_id = _principal_actor(principal) + if not actor_id or actor_id != request.elector_id: + raise LocalConfidentialVotingError( + "A confidential ballot principal cannot cast for another elector." + ) + ballot = self._ballot( + db, + tenant_id=request.tenant_id, + provider_ballot_ref=request.provider_ballot_ref, + lock=True, + ) + self._validate_binding( + ballot, + ballot_id=request.ballot_id, + definition_sha256=request.definition_sha256, + electorate_sha256=request.electorate_sha256, + ) + if ballot.state != "open": + raise LocalConfidentialVotingError( + "The confidential provider ballot is not open." + ) + _validate_window(ballot) + electorate = {str(item["subject_id"]): item for item in ballot.electorate} + elector = electorate.get(request.elector_id) + if elector is None: + raise LocalConfidentialVotingError( + "The current principal is not in the confidential provider electorate." + ) + selections = tuple(str(item).strip() for item in request.selections) + _validate_selections(ballot, selections) + if request.allow_replacement != ballot.allow_replacement: + raise LocalConfidentialVotingError( + "The confidential provider replacement policy does not match the frozen ballot." + ) + cipher = encryption_content_cipher(self._registry) + if cipher is None: + raise LocalConfidentialVotingError( + "The confidential provider cannot cast because Encryption is unavailable." + ) + replay = ( + db.query(VotingConfidentialCast) + .filter( + VotingConfidentialCast.tenant_id == request.tenant_id, + VotingConfidentialCast.provider_ballot_id == ballot.id, + VotingConfidentialCast.idempotency_key == request.idempotency_key, + ) + .one_or_none() + ) + if replay is not None: + opened = cipher.unprotect_content( + db, + request=ContentUnprotectionRequest( + tenant_id=request.tenant_id, + owner_module="voting", + resource_type="voting_confidential_cast", + resource_id=replay.encryption_resource_id, + envelope_id=replay.encryption_envelope_id, + ciphertext=replay.ciphertext, + actor_id=actor_id, + ), + ) + stored_selections = _validate_decrypted_cast( + replay, + ballot, + json.loads(opened.decode("utf-8")), + ) + request_digest = _confidential_cast_request_digest( + request, + generation=replay.generation, + ciphertext=replay.ciphertext, + ) + if replay.request_sha256 != request_digest: + raise LocalConfidentialVotingError( + "The confidential vote idempotency key was reused for another vote." + ) + if stored_selections != selections: + raise LocalConfidentialVotingError( + "The confidential vote idempotency key was reused for another vote." + ) + return VotingReceipt( + ballot_id=request.ballot_id, + revision=0, + receipt_sha256=replay.receipt_sha256, + cast_at=_aware(replay.cast_at), + replaced_previous=replay.generation > 1, + replayed=True, + ) + previous = ( + db.query(VotingConfidentialCast) + .filter( + VotingConfidentialCast.tenant_id == request.tenant_id, + VotingConfidentialCast.provider_ballot_id == ballot.id, + VotingConfidentialCast.elector_id == request.elector_id, + VotingConfidentialCast.superseded_at.is_(None), + ) + .with_for_update() + .one_or_none() + ) + if previous is not None and not ballot.allow_replacement: + raise LocalConfidentialVotingError( + "This confidential ballot does not allow vote replacement." + ) + generation = previous.generation + 1 if previous is not None else 1 + cast_at = request.requested_at + cast_id = str(uuid.uuid4()) + plaintext = _confidential_cast_plaintext( + request, + generation=generation, + selections=selections, + ) + protected = cipher.protect_content( + db, + request=ContentProtectionRequest( + tenant_id=request.tenant_id, + owner_module="voting", + resource_type="voting_confidential_cast", + resource_id=cast_id, + profile_id="voting-local-confidential-v1", + vault_id=ballot.vault_id, + ciphertext_ref=f"voting-db://confidential-casts/{cast_id}", + plaintext=plaintext, + policy_decision_ref="voting:local-confidential:v1", + idempotency_key=f"voting-confidential-cast:{cast_id}", + actor_id=actor_id, + metadata={"ballot_id": request.ballot_id, "generation": generation}, + ), + ) + request_digest = _confidential_cast_request_digest( + request, + generation=generation, + ciphertext=protected.ciphertext, + ) + receipt_hash = _digest( + { + "tenant_id": request.tenant_id, + "ballot_id": request.ballot_id, + "definition_sha256": request.definition_sha256, + "elector_id": request.elector_id, + "generation": generation, + "ciphertext_digest": protected.envelope.ciphertext_digest, + "cast_at": cast_at.isoformat(), + "idempotency_key": request.idempotency_key, + } + ) + if previous is not None: + previous.superseded_at = cast_at + db.add( + VotingConfidentialCast( + id=cast_id, + tenant_id=request.tenant_id, + provider_ballot_id=ballot.id, + elector_id=request.elector_id, + generation=generation, + definition_sha256=request.definition_sha256, + ciphertext=protected.ciphertext, + encryption_envelope_id=protected.envelope.envelope_id, + encryption_resource_id=cast_id, + weight=int(elector.get("weight") or 1), + cast_at=cast_at, + idempotency_key=request.idempotency_key, + request_sha256=request_digest, + receipt_sha256=receipt_hash, + ) + ) + db.flush() + return VotingReceipt( + ballot_id=request.ballot_id, + revision=0, + receipt_sha256=receipt_hash, + cast_at=cast_at, + replaced_previous=previous is not None, + ) + + def finalize_ballot( + self, + session: object, + principal: object, + *, + request: ExternalVotingFinalizationRequest, + ) -> VotingResult: + db = _session(session) + _require_tenant(principal, request.tenant_id) + ballot = self._ballot( + db, + tenant_id=request.tenant_id, + provider_ballot_ref=request.provider_ballot_ref, + lock=True, + ) + self._validate_binding( + ballot, + ballot_id=request.ballot_id, + definition_sha256=request.definition_sha256, + electorate_sha256=request.electorate_sha256, + ) + if ballot.result is not None: + return _result_from_mapping(ballot.result) + if ballot.state != "open": + raise LocalConfidentialVotingError( + "The confidential provider ballot cannot be finalized from its current state." + ) + option_keys = tuple(str(item["key"]) for item in ballot.options) + if option_keys != tuple(item.key for item in request.options): + raise LocalConfidentialVotingError( + "The confidential provider options do not match the frozen ballot." + ) + if request.eligible_count != len( + ballot.electorate + ) or request.eligible_weight != sum( + int(item.get("weight") or 1) for item in ballot.electorate + ): + raise LocalConfidentialVotingError( + "The confidential provider electorate totals do not match the frozen ballot." + ) + cipher = encryption_content_cipher(self._registry) + if cipher is None: + raise LocalConfidentialVotingError( + "The confidential provider cannot tally because Encryption is unavailable." + ) + rows = ( + db.query(VotingConfidentialCast) + .filter( + VotingConfidentialCast.tenant_id == request.tenant_id, + VotingConfidentialCast.provider_ballot_id == ballot.id, + VotingConfidentialCast.superseded_at.is_(None), + ) + .order_by(VotingConfidentialCast.id.asc()) + .all() + ) + counts = {key: 0 for key in option_keys} + weighted = {key: 0 for key in option_keys} + for row in rows: + opened = cipher.unprotect_content( + db, + request=ContentUnprotectionRequest( + tenant_id=request.tenant_id, + owner_module="voting", + resource_type="voting_confidential_cast", + resource_id=row.encryption_resource_id, + envelope_id=row.encryption_envelope_id, + ciphertext=row.ciphertext, + actor_id=_principal_actor(principal), + ), + ) + item = json.loads(opened.decode("utf-8")) + selections = _validate_decrypted_cast(row, ballot, item) + for selection in selections: + counts[selection] += 1 + weighted[selection] += row.weight + cast_weight = sum(row.weight for row in rows) + eligible_weight = sum( + int(item.get("weight") or 1) for item in ballot.electorate + ) + quorum_met = cast_weight >= ballot.quorum_weight + max_weight = max(weighted.values(), default=0) + winners = tuple( + key for key in option_keys if max_weight > 0 and weighted[key] == max_weight + ) + threshold_met = bool( + quorum_met + and winners + and max_weight * ballot.threshold_denominator + >= max(cast_weight, 1) * ballot.threshold_numerator + ) + result_body = { + "ballot_id": ballot.ballot_id, + "counts": counts, + "weighted_counts": weighted, + "cast_count": len(rows), + "cast_weight": cast_weight, + "eligible_count": len(ballot.electorate), + "eligible_weight": eligible_weight, + "quorum_met": quorum_met, + "threshold_met": threshold_met, + "winning_options": winners, + "definition_sha256": ballot.definition_sha256, + "electorate_sha256": ballot.electorate_sha256, + } + result_sha256 = _digest(result_body) + evidence = ( + { + "kind": "reference_provider_result", + "provider_id": LOCAL_CONFIDENTIAL_PROVIDER_ID, + "provider_ballot_ref": ballot.provider_ballot_ref, + "assurance_profile": "confidential", + "protection_profile": "server_envelope", + "definition_sha256": ballot.definition_sha256, + "electorate_sha256": ballot.electorate_sha256, + "active_receipts_sha256": _digest( + sorted(row.receipt_sha256 for row in rows) + ), + "certified": False, + }, + ) + result = VotingResult( + ballot_id=ballot.ballot_id, + revision=0, + counts=counts, + weighted_counts=weighted, + cast_count=len(rows), + cast_weight=cast_weight, + eligible_count=len(ballot.electorate), + eligible_weight=eligible_weight, + quorum_met=quorum_met, + threshold_met=threshold_met, + winning_options=winners, + result_sha256=result_sha256, + evidence=evidence, + ) + ballot.state = "closed" + ballot.finalization_idempotency_key = request.idempotency_key + ballot.finalized_at = request.requested_at + ballot.result = _result_mapping(result) + db.flush() + return result + + def ballot_status( + self, + session: object, + principal: object, + *, + tenant_id: str, + provider_ballot_ref: str, + ) -> ExternalVotingBallotRef | None: + db = _session(session) + _require_tenant(principal, tenant_id) + row = ( + db.query(VotingConfidentialBallot) + .filter( + VotingConfidentialBallot.tenant_id == tenant_id, + VotingConfidentialBallot.provider_ballot_ref == provider_ballot_ref, + ) + .one_or_none() + ) + return self._ballot_ref(row) if row is not None else None + + @staticmethod + def _validate_binding( + ballot: VotingConfidentialBallot, + *, + ballot_id: str, + definition_sha256: str, + electorate_sha256: str, + ) -> None: + if ( + ballot.ballot_id != ballot_id + or ballot.definition_sha256 != definition_sha256 + or ballot.electorate_sha256 != electorate_sha256 + ): + raise LocalConfidentialVotingError( + "The confidential provider request does not match the frozen ballot." + ) + + @staticmethod + def _ballot_ref( + row: VotingConfidentialBallot, + *, + replayed: bool = False, + ) -> ExternalVotingBallotRef: + return ExternalVotingBallotRef( + provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID, + provider_ballot_ref=row.provider_ballot_ref, + state="closed" if row.state == "closed" else "open", + definition_sha256=row.definition_sha256, + electorate_sha256=row.electorate_sha256, + evidence=( + { + "kind": "reference_provider_preparation", + "assurance_profile": "confidential", + "protection_profile": "server_envelope", + "replayed": replayed, + "certified": False, + }, + ), + ) + + @staticmethod + def _ballot( + session: Session, + *, + tenant_id: str, + provider_ballot_ref: str, + lock: bool, + ) -> VotingConfidentialBallot: + query = session.query(VotingConfidentialBallot).filter( + VotingConfidentialBallot.tenant_id == tenant_id, + VotingConfidentialBallot.provider_ballot_ref == provider_ballot_ref, + ) + if lock: + query = query.with_for_update() + row = query.one_or_none() + if row is None: + raise LocalConfidentialVotingError( + "The confidential provider ballot was not found." + ) + return row + + +def _preparation_payload( + request: ExternalVotingPreparationRequest, + *, + provider_ref: str, +) -> dict[str, Any]: + return { + "tenant_id": request.tenant_id, + "ballot_id": request.ballot_id, + "provider_ballot_ref": provider_ref, + "definition_sha256": request.definition_sha256, + "electorate_sha256": request.electorate_sha256, + "assurance_profile": request.assurance_profile, + "method": request.method, + "options": [asdict(item) for item in request.options], + "electorate": [asdict(item) for item in request.electorate], + "allow_replacement": request.allow_replacement, + "quorum_weight": request.quorum_weight, + "threshold_numerator": request.threshold_numerator, + "threshold_denominator": request.threshold_denominator, + "opens_at": request.opens_at, + "closes_at": request.closes_at, + "idempotency_key": request.idempotency_key, + } + + +def _validate_selections( + ballot: VotingConfidentialBallot, + selections: tuple[str, ...], +) -> None: + option_keys = {str(item["key"]) for item in ballot.options} + if ( + not selections + or len(selections) != len(set(selections)) + or not set(selections) <= option_keys + ): + raise LocalConfidentialVotingError( + "Confidential selections must be unique configured option keys." + ) + if ballot.method in {"single_choice", "yes_no_abstain"} and len(selections) != 1: + raise LocalConfidentialVotingError( + "This confidential Voting method requires exactly one selection." + ) + + +def _validate_decrypted_cast( + row: VotingConfidentialCast, + ballot: VotingConfidentialBallot, + value: object, +) -> tuple[str, ...]: + if not isinstance(value, dict): + raise LocalConfidentialVotingError( + "A confidential cast payload has an invalid structure." + ) + selections = tuple(str(item) for item in value.get("selections", ())) + if ( + value.get("ballot_id") != ballot.ballot_id + or value.get("definition_sha256") != ballot.definition_sha256 + or value.get("elector_id") != row.elector_id + or value.get("generation") != row.generation + ): + raise LocalConfidentialVotingError( + "A confidential cast payload does not match its authenticated record." + ) + _validate_selections(ballot, selections) + return selections + + +def _confidential_cast_plaintext( + request: ExternalVotingCastRequest, + *, + generation: int, + selections: tuple[str, ...], +) -> bytes: + return _canonical_json( + { + "ballot_id": request.ballot_id, + "definition_sha256": request.definition_sha256, + "elector_id": request.elector_id, + "generation": generation, + "selections": selections, + } + ) + + +def _confidential_cast_request_digest( + request: ExternalVotingCastRequest, + *, + generation: int, + ciphertext: bytes, +) -> str: + return _digest( + { + "tenant_id": request.tenant_id, + "ballot_id": request.ballot_id, + "provider_ballot_ref": request.provider_ballot_ref, + "definition_sha256": request.definition_sha256, + "electorate_sha256": request.electorate_sha256, + "elector_id": request.elector_id, + "allow_replacement": request.allow_replacement, + "generation": generation, + "ciphertext_sha256": hashlib.sha256(ciphertext).hexdigest(), + "idempotency_key": request.idempotency_key, + } + ) + + +def _validate_window(ballot: VotingConfidentialBallot) -> None: + now = datetime.now(UTC) + if ballot.opens_at is not None and now < _aware(ballot.opens_at): + raise LocalConfidentialVotingError( + "The confidential provider ballot has not opened yet." + ) + if ballot.closes_at is not None and now >= _aware(ballot.closes_at): + raise LocalConfidentialVotingError( + "The confidential provider ballot has reached its close time." + ) + + +def _result_mapping(result: VotingResult) -> dict[str, Any]: + return { + "ballot_id": result.ballot_id, + "revision": result.revision, + "counts": dict(result.counts), + "weighted_counts": dict(result.weighted_counts), + "cast_count": result.cast_count, + "cast_weight": result.cast_weight, + "eligible_count": result.eligible_count, + "eligible_weight": result.eligible_weight, + "quorum_met": result.quorum_met, + "threshold_met": result.threshold_met, + "winning_options": list(result.winning_options), + "result_sha256": result.result_sha256, + "evidence": [dict(item) for item in result.evidence], + } + + +def _result_from_mapping(value: dict[str, Any]) -> VotingResult: + return VotingResult( + ballot_id=str(value["ballot_id"]), + revision=int(value.get("revision") or 0), + counts={str(key): int(item) for key, item in value["counts"].items()}, + weighted_counts={ + str(key): int(item) for key, item in value["weighted_counts"].items() + }, + cast_count=int(value["cast_count"]), + cast_weight=int(value["cast_weight"]), + eligible_count=int(value["eligible_count"]), + eligible_weight=int(value["eligible_weight"]), + quorum_met=bool(value["quorum_met"]), + threshold_met=bool(value["threshold_met"]), + winning_options=tuple(str(item) for item in value["winning_options"]), + result_sha256=str(value["result_sha256"]), + evidence=tuple(dict(item) for item in value.get("evidence", ())), + ) + + +def _require_tenant(principal: object, tenant_id: str) -> None: + if str(getattr(principal, "tenant_id", "") or "").strip() != tenant_id: + raise LocalConfidentialVotingError( + "The confidential provider principal belongs to another tenant." + ) + + +def _principal_actor(principal: object) -> str | None: + for name in ("account_id", "user_id", "identity_id", "membership_id", "subject"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise LocalConfidentialVotingError( + "The confidential provider requires a database session." + ) + return value # type: ignore[return-value] + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + + +def _digest(value: object) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +__all__ = [ + "LOCAL_CONFIDENTIAL_PROVIDER_ID", + "LocalConfidentialVotingError", + "LocalConfidentialVotingProvider", +] diff --git a/src/govoplan_voting/backend/manifest.py b/src/govoplan_voting/backend/manifest.py index d26f216..9f45a6c 100644 --- a/src/govoplan_voting/backend/manifest.py +++ b/src/govoplan_voting/backend/manifest.py @@ -6,6 +6,10 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) +from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + CAPABILITY_ENCRYPTION_KEY_VAULT, +) from govoplan_core.core.module_guards import ( drop_table_retirement_provider, persistent_table_uninstall_guard, @@ -19,6 +23,7 @@ from govoplan_core.core.modules import ( MigrationSpec, ModuleContext, ModuleInterfaceProvider, + ModuleInterfaceRequirement, ModuleManifest, NavItem, PermissionDefinition, @@ -26,10 +31,17 @@ from govoplan_core.core.modules import ( ) from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface -from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS +from govoplan_core.core.voting import ( + CAPABILITY_VOTING_BALLOTS, + voting_provider_capability, +) from govoplan_core.db.base import Base from govoplan_voting.backend.db import models as voting_models from govoplan_voting.backend.service import SqlVotingBallots +from govoplan_voting.backend.local_confidential_provider import ( + LOCAL_CONFIDENTIAL_PROVIDER_ID, + LocalConfidentialVotingProvider, +) MODULE_ID = "voting" @@ -67,6 +79,12 @@ def _ballots(context: ModuleContext) -> SqlVotingBallots: return SqlVotingBallots(context.registry) +def _local_confidential_provider( + context: ModuleContext, +) -> LocalConfidentialVotingProvider: + return LocalConfidentialVotingProvider(context.registry) + + def _tenant_summary(session, tenant_id: str) -> dict[str, int]: current = session.query(voting_models.VotingBallotRevision).filter( voting_models.VotingBallotRevision.tenant_id == tenant_id, @@ -101,6 +119,24 @@ manifest = ModuleManifest( ), provides_interfaces=( ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"), + ModuleInterfaceProvider( + name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID), + version="0.1.0", + ), + ), + requires_interfaces=( + ModuleInterfaceRequirement( + name=CAPABILITY_ENCRYPTION_KEY_VAULT, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), ), permissions=( _permission( @@ -210,7 +246,12 @@ manifest = ModuleManifest( ), ), ), - capability_factories={CAPABILITY_VOTING_BALLOTS: _ballots}, + capability_factories={ + CAPABILITY_VOTING_BALLOTS: _ballots, + voting_provider_capability( + LOCAL_CONFIDENTIAL_PROVIDER_ID + ): _local_confidential_provider, + }, capability_documentation={ CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation( label="Governed ballots", @@ -226,6 +267,8 @@ manifest = ModuleManifest( retirement_provider=drop_table_retirement_provider( voting_models.VotingCommandReplay, voting_models.VotingLifecycleEvent, + voting_models.VotingConfidentialCast, + voting_models.VotingConfidentialBallot, voting_models.VotingCastRecord, voting_models.VotingBallotRevision, label=MODULE_NAME, @@ -238,6 +281,8 @@ manifest = ModuleManifest( voting_models.VotingCastRecord, voting_models.VotingLifecycleEvent, voting_models.VotingCommandReplay, + voting_models.VotingConfidentialBallot, + voting_models.VotingConfidentialCast, label=MODULE_NAME, ), ), @@ -250,6 +295,7 @@ manifest = ModuleManifest( body=( "Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. " "Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. " + "The bundled local confidential provider encrypts raw selections through Encryption and supports interactive casting, but remains server-readable and uncertified. " "Closure, certification, challenge, and annulment remain separate auditable transitions." ), layer="configured", @@ -273,6 +319,7 @@ manifest = ModuleManifest( known_limits=( "The native profile is recorded and reconstructable, not cryptographically secret.", "Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.", + "The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.", "Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.", ), supported_authority_modes=("native_authoritative", "external_authoritative"), diff --git a/src/govoplan_voting/backend/migrations/versions/8b9c0d1e2f3a_v0115_local_confidential_provider.py b/src/govoplan_voting/backend/migrations/versions/8b9c0d1e2f3a_v0115_local_confidential_provider.py new file mode 100644 index 0000000..440386f --- /dev/null +++ b/src/govoplan_voting/backend/migrations/versions/8b9c0d1e2f3a_v0115_local_confidential_provider.py @@ -0,0 +1,142 @@ +"""v0.1.15 local confidential Voting provider. + +Revision ID: 8b9c0d1e2f3a +Revises: 7a8b9c0d1e2f +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "8b9c0d1e2f3a" +down_revision = "7a8b9c0d1e2f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "voting_confidential_ballots", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("provider_ballot_ref", sa.String(length=255), nullable=False), + sa.Column("ballot_id", sa.String(length=36), nullable=False), + sa.Column("definition_sha256", sa.String(length=64), nullable=False), + sa.Column("electorate_sha256", sa.String(length=64), nullable=False), + sa.Column("assurance_profile", sa.String(length=40), nullable=False), + sa.Column("method", sa.String(length=40), nullable=False), + sa.Column("state", sa.String(length=30), nullable=False), + sa.Column("options", sa.JSON(), nullable=False), + sa.Column("electorate", sa.JSON(), nullable=False), + sa.Column("allow_replacement", sa.Boolean(), nullable=False), + sa.Column("quorum_weight", sa.Integer(), nullable=False), + sa.Column("threshold_numerator", sa.Integer(), nullable=False), + sa.Column("threshold_denominator", sa.Integer(), nullable=False), + sa.Column("opens_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("closes_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("vault_id", sa.String(length=255), nullable=False), + sa.Column("preparation_idempotency_key", sa.String(length=160), nullable=False), + sa.Column("preparation_request_sha256", sa.String(length=64), nullable=False), + sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finalization_idempotency_key", sa.String(length=160), nullable=True), + sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_confidential_ballots")), + sa.UniqueConstraint( + "tenant_id", + "provider_ballot_ref", + name="uq_voting_confidential_provider_ref", + ), + sa.UniqueConstraint( + "tenant_id", + "ballot_id", + name="uq_voting_confidential_ballot", + ), + ) + for column in ("tenant_id", "provider_ballot_ref", "ballot_id", "state"): + op.create_index( + op.f(f"ix_voting_confidential_ballots_{column}"), + "voting_confidential_ballots", + [column], + unique=False, + ) + op.create_index( + "ix_voting_confidential_ballot_state", + "voting_confidential_ballots", + ["tenant_id", "state", "prepared_at"], + unique=False, + ) + + op.create_table( + "voting_confidential_casts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("provider_ballot_id", sa.String(length=36), nullable=False), + sa.Column("elector_id", sa.String(length=255), nullable=False), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("definition_sha256", sa.String(length=64), nullable=False), + sa.Column("ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("encryption_envelope_id", sa.String(length=255), nullable=False), + sa.Column("encryption_resource_id", sa.String(length=36), nullable=False), + sa.Column("weight", sa.Integer(), nullable=False), + sa.Column("cast_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("receipt_sha256", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["provider_ballot_id"], + ["voting_confidential_ballots.id"], + name=op.f( + "fk_voting_confidential_casts_provider_ballot_id_voting_confidential_ballots" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_confidential_casts")), + sa.UniqueConstraint( + "tenant_id", + "provider_ballot_id", + "elector_id", + "generation", + name="uq_voting_confidential_cast_generation", + ), + sa.UniqueConstraint( + "tenant_id", + "provider_ballot_id", + "idempotency_key", + name="uq_voting_confidential_cast_idempotency", + ), + ) + for column in ( + "tenant_id", + "provider_ballot_id", + "elector_id", + "encryption_envelope_id", + "encryption_resource_id", + "cast_at", + "superseded_at", + "receipt_sha256", + ): + op.create_index( + op.f(f"ix_voting_confidential_casts_{column}"), + "voting_confidential_casts", + [column], + unique=False, + ) + op.create_index( + "ix_voting_confidential_cast_active", + "voting_confidential_casts", + ["tenant_id", "provider_ballot_id", "elector_id", "superseded_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("voting_confidential_casts") + op.drop_table("voting_confidential_ballots") diff --git a/src/govoplan_voting/backend/service.py b/src/govoplan_voting/backend/service.py index dff1769..130b239 100644 --- a/src/govoplan_voting/backend/service.py +++ b/src/govoplan_voting/backend/service.py @@ -12,8 +12,11 @@ from sqlalchemy import func from sqlalchemy.orm import Session from govoplan_core.core.voting import ( + ExternalVotingCastRequest, ExternalVotingFinalizationRequest, + ExternalVotingPreparationRequest, ExternalVotingProvider, + InteractiveExternalVotingProvider, VOTING_ASSURANCE_RECORDED, VotingBallotCreateCommand, VotingBallotRef, @@ -250,19 +253,81 @@ class SqlVotingBallots: payload = dict(current.payload) _validate_draft(payload) profile = str(payload["assurance_profile"]) + provider: object | None = None if profile != VOTING_ASSURANCE_RECORDED: provider_id = str(payload.get("provider_id") or "").strip() - if ( - not provider_id - or _capability(self._registry, voting_provider_capability(provider_id)) - is None - ): + provider = ( + _capability(self._registry, voting_provider_capability(provider_id)) + if provider_id + else None + ) + if not isinstance(provider, ExternalVotingProvider): raise VotingStoreError( "The selected Voting assurance profile requires an available external provider." ) definition_hash, electorate_hash = _frozen_hashes(payload) payload["definition_sha256"] = definition_hash payload["electorate_sha256"] = electorate_hash + provider_evidence: tuple[Mapping[str, object], ...] = () + if isinstance(provider, InteractiveExternalVotingProvider): + provider_id = str(payload["provider_id"]) + try: + prepared = provider.prepare_ballot( + typed_session, + principal, + request=ExternalVotingPreparationRequest( + tenant_id=current.tenant_id, + ballot_id=current.ballot_id, + requested_provider_ballot_ref=_optional_text( + payload.get("provider_ballot_ref") + ), + definition_sha256=definition_hash, + electorate_sha256=electorate_hash, + assurance_profile=profile, # type: ignore[arg-type] + method=str(payload["method"]), + options=tuple( + _option_from_mapping(item) for item in payload["options"] + ), + electorate=tuple( + _elector_from_mapping(item) + for item in payload["electorate"] + ), + allow_replacement=bool(payload.get("allow_replacement")), + quorum_weight=int(payload.get("quorum_weight") or 0), + threshold_numerator=int( + payload.get("threshold_numerator") or 1 + ), + threshold_denominator=int( + payload.get("threshold_denominator") or 2 + ), + opens_at=_parse_datetime(payload.get("opens_at")), + closes_at=_parse_datetime(payload.get("closes_at")), + requested_at=_now(), + idempotency_key=f"open:{_idempotency(idempotency_key)}", + ), + ) + except ValueError as exc: + raise VotingStoreError( + "Voting provider preparation was rejected." + ) from exc + if ( + prepared.provider_id != provider_id + or prepared.definition_sha256 != definition_hash + or prepared.electorate_sha256 != electorate_hash + or prepared.state not in {"prepared", "open"} + ): + raise VotingStoreError( + "Voting provider preparation did not match the frozen ballot." + ) + payload["provider_ballot_ref"] = prepared.provider_ballot_ref + provider_evidence = tuple(prepared.evidence) + elif ( + profile != VOTING_ASSURANCE_RECORDED + and not str(payload.get("provider_ballot_ref") or "").strip() + ): + raise VotingStoreError( + "This external Voting provider requires a ballot reference before opening." + ) revised = _revise( typed_session, current=current, @@ -276,6 +341,9 @@ class SqlVotingBallots: "revision": current.revision + 1, "definition_sha256": definition_hash, "electorate_sha256": electorate_hash, + "provider_id": payload.get("provider_id"), + "provider_ballot_ref": payload.get("provider_ballot_ref"), + "provider_evidence": [dict(item) for item in provider_evidence], }, ) result = _ref(revised) @@ -301,10 +369,6 @@ class SqlVotingBallots: current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) if current.state != "open": raise VotingStoreError("Only an open Voting ballot accepts votes.") - if current.assurance_profile != VOTING_ASSURANCE_RECORDED: - raise VotingStoreError( - "Votes for this assurance profile must be cast through its external provider." - ) _validate_window(current.payload) actor_id = _principal_actor(principal) elector_id = str(command.elector_id or actor_id or "").strip() @@ -328,6 +392,63 @@ class SqlVotingBallots: ) _validate_selections(current.payload, selections) idempotency_key = _idempotency(command.idempotency_key) + if current.assurance_profile != VOTING_ASSURANCE_RECORDED: + provider_id = str(current.payload.get("provider_id") or "").strip() + provider_ref = str(current.payload.get("provider_ballot_ref") or "").strip() + provider = _capability( + self._registry, + voting_provider_capability(provider_id), + ) + if not isinstance(provider, InteractiveExternalVotingProvider): + raise VotingStoreError( + "This Voting provider does not expose an interactive cast capability." + ) + try: + receipt = provider.cast_ballot( + typed_session, + principal, + request=ExternalVotingCastRequest( + tenant_id=current.tenant_id, + ballot_id=current.ballot_id, + provider_ballot_ref=provider_ref, + definition_sha256=str(current.definition_sha256), + electorate_sha256=str(current.electorate_sha256), + elector_id=elector_id, + selections=selections, + allow_replacement=bool( + current.payload.get("allow_replacement") + ), + requested_at=_now(), + idempotency_key=idempotency_key, + ), + ) + except ValueError as exc: + raise VotingStoreError("Voting provider cast was rejected.") from exc + if receipt.ballot_id != current.ballot_id: + raise VotingStoreError( + "Voting provider returned a receipt for another ballot." + ) + normalized = VotingReceipt( + ballot_id=current.ballot_id, + revision=current.revision, + receipt_sha256=receipt.receipt_sha256, + cast_at=receipt.cast_at, + replaced_previous=receipt.replaced_previous, + replayed=receipt.replayed, + ) + if not normalized.replayed: + _record_event( + typed_session, + row=current, + event_type="ballot.vote_cast", + principal=principal, + payload={ + "receipt_sha256": normalized.receipt_sha256, + "provider_id": provider_id, + "replaced_previous": normalized.replaced_previous, + }, + ) + return normalized replay = ( typed_session.query(VotingCastRecord) .filter( @@ -743,26 +864,32 @@ class SqlVotingBallots: if not isinstance(provider, ExternalVotingProvider): raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.") electorate = list(current.payload["electorate"]) - result = provider.finalize_ballot( - session, - principal, - request=ExternalVotingFinalizationRequest( - tenant_id=current.tenant_id, - ballot_id=current.ballot_id, - provider_ballot_ref=provider_ref, - definition_sha256=str(current.definition_sha256), - electorate_sha256=str(current.electorate_sha256), - options=tuple( - _option_from_mapping(item) for item in current.payload["options"] + try: + result = provider.finalize_ballot( + session, + principal, + request=ExternalVotingFinalizationRequest( + tenant_id=current.tenant_id, + ballot_id=current.ballot_id, + provider_ballot_ref=provider_ref, + definition_sha256=str(current.definition_sha256), + electorate_sha256=str(current.electorate_sha256), + options=tuple( + _option_from_mapping(item) + for item in current.payload["options"] + ), + eligible_count=len(electorate), + eligible_weight=sum( + int(item.get("weight") or 1) for item in electorate + ), + requested_at=_now(), + idempotency_key=_idempotency(idempotency_key), ), - eligible_count=len(electorate), - eligible_weight=sum( - int(item.get("weight") or 1) for item in electorate - ), - requested_at=_now(), - idempotency_key=_idempotency(idempotency_key), - ), - ) + ) + except ValueError as exc: + raise VotingStoreError( + "Voting provider finalization was rejected." + ) from exc option_keys = {str(item["key"]) for item in current.payload["options"]} if result.ballot_id != current.ballot_id: raise VotingStoreError( @@ -871,13 +998,8 @@ def _validate_draft(payload: Mapping[str, Any]) -> None: if opens_at and closes_at and closes_at <= opens_at: raise VotingStoreError("Voting close time must be after its open time.") if assurance != VOTING_ASSURANCE_RECORDED: - if ( - not str(payload.get("provider_id") or "").strip() - or not str(payload.get("provider_ballot_ref") or "").strip() - ): - raise VotingStoreError( - "External Voting assurance requires provider id and ballot reference." - ) + if not str(payload.get("provider_id") or "").strip(): + raise VotingStoreError("External Voting assurance requires a provider id.") def _validate_selections( @@ -919,6 +1041,7 @@ def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]: "definition_sha256", "result", "certification", + "provider_ballot_ref", } } return _sha256(definition), _sha256(electorate) @@ -1169,6 +1292,17 @@ def _option_from_mapping(value: Mapping[str, Any]): ) +def _elector_from_mapping(value: Mapping[str, Any]): + from govoplan_core.core.voting import VotingElector + + return VotingElector( + subject_id=str(value["subject_id"]), + label=_optional_text(value.get("label")), + weight=int(value.get("weight") or 1), + provenance=dict(value.get("provenance") or {}), + ) + + def _sha256(value: object) -> str: encoded = json.dumps( value, diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d080129..6ce0d72 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -29,11 +29,13 @@ class VotingMigrationTests(unittest.TestCase): "voting_cast_records", "voting_lifecycle_events", "voting_command_replays", + "voting_confidential_ballots", + "voting_confidential_casts", }.issubset(tables) ) with engine.connect() as connection: self.assertIn( - "7a8b9c0d1e2f", + "8b9c0d1e2f3a", set(MigrationContext.configure(connection).get_current_heads()), ) finally: diff --git a/tests/test_voting.py b/tests/test_voting.py index d2dcd34..2706797 100644 --- a/tests/test_voting.py +++ b/tests/test_voting.py @@ -1,6 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, replace +from datetime import UTC, datetime +import hashlib +from types import SimpleNamespace import unittest from sqlalchemy import create_engine @@ -11,9 +14,23 @@ from govoplan_core.core.voting import ( VotingCastCommand, VotingElector, VotingOption, + voting_provider_capability, +) +from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + CAPABILITY_ENCRYPTION_KEY_VAULT, + ContentProtectionEnvelope, + ProtectedContent, ) from govoplan_core.db.base import Base -from govoplan_voting.backend.db.models import VotingCastRecord +from govoplan_voting.backend.db.models import ( + VotingCastRecord, + VotingConfidentialCast, +) +from govoplan_voting.backend.local_confidential_provider import ( + LOCAL_CONFIDENTIAL_PROVIDER_ID, + LocalConfidentialVotingProvider, +) from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError @@ -23,6 +40,87 @@ class Principal: account_id: str +class FakeRegistry: + def __init__(self) -> None: + self.capabilities: dict[str, object] = {} + + def has_capability(self, name: str) -> bool: + return name in self.capabilities + + def capability(self, name: str) -> object | None: + return self.capabilities.get(name) + + +class FakeKeyVault: + def __init__(self) -> None: + self.vaults: dict[tuple[str, str], object] = {} + + def create_vault(self, session, principal, *, request): + del session, principal + vault = SimpleNamespace(state="active", current_key=SimpleNamespace(version=1)) + self.vaults[(request.tenant_id, request.vault_id)] = vault + return vault + + def get_vault(self, session, *, tenant_id, vault_id): + del session + return self.vaults.get((tenant_id, vault_id)) + + def rotate_key(self, *args, **kwargs): + raise NotImplementedError + + def revoke_key(self, *args, **kwargs): + raise NotImplementedError + + def schedule_destruction(self, *args, **kwargs): + raise NotImplementedError + + def reconcile_vault(self, *args, **kwargs): + raise NotImplementedError + + +class FakeContentCipher: + def __init__(self) -> None: + self.plaintexts: dict[str, bytes] = {} + + def protect_content(self, session, *, request): + del session + envelope_id = f"envelope-{request.resource_id}" + ciphertext = b"ciphertext:" + hashlib.sha256(request.plaintext).digest() + self.plaintexts[envelope_id] = request.plaintext + return ProtectedContent( + envelope=ContentProtectionEnvelope( + envelope_id=envelope_id, + tenant_id=request.tenant_id, + owner_module=request.owner_module, + resource_type=request.resource_type, + resource_id=request.resource_id, + profile_kind="server_envelope", + profile_id=request.profile_id, + provider_id="fake", + vault_id=request.vault_id, + key_version=1, + algorithm_suite="AES-256-GCM", + ciphertext_ref=request.ciphertext_ref, + ciphertext_digest=hashlib.sha256(ciphertext).hexdigest(), + authenticated_context_digest="a" * 64, + state="active", + created_at=datetime.now(UTC), + wrapped_key_refs=(f"wrapped-{request.resource_id}",), + ), + ciphertext=ciphertext, + ) + + def unprotect_content(self, session, *, request): + del session + return self.plaintexts[request.envelope_id] + + def execute_rewrap(self, *args, **kwargs): + raise NotImplementedError + + def prepare_reencryption(self, *args, **kwargs): + raise NotImplementedError + + def command(*, assurance: str = "recorded", provider_id: str | None = None): return VotingBallotCreateCommand( title="Budget choice", @@ -192,6 +290,99 @@ class VotingTests(unittest.TestCase): idempotency_key="open-secret", ) + def test_local_confidential_provider_encrypts_casts_and_returns_aggregates( + self, + ) -> None: + registry = FakeRegistry() + provider = LocalConfidentialVotingProvider(registry) + registry.capabilities.update( + { + voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID): provider, + CAPABILITY_ENCRYPTION_KEY_VAULT: FakeKeyVault(), + CAPABILITY_ENCRYPTION_CONTENT_CIPHER: FakeContentCipher(), + } + ) + service = SqlVotingBallots(registry) + draft = replace( + command( + assurance="confidential", + provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID, + ), + provider_ballot_ref=None, + ) + with self.Session() as session: + created = service.create_ballot( + session, + self.manager, + command=draft, + idempotency_key="create-confidential", + ) + opened = service.open_ballot( + session, + self.manager, + ballot_id=created.id, + expected_revision=created.revision, + idempotency_key="open-confidential", + ) + detail = service.get_ballot( + session, + self.manager, + ballot_id=opened.id, + ) + self.assertEqual( + f"local-confidential:{opened.id}", + detail["provider_ballot_ref"], + ) + first = service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand(("a",), "cast-confidential-alice"), + ) + replay = service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand(("a",), "cast-confidential-alice"), + ) + self.assertEqual(first.receipt_sha256, replay.receipt_sha256) + self.assertTrue(replay.replayed) + with self.assertRaisesRegex( + VotingStoreError, + "provider cast was rejected", + ): + service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand( + ("b",), + "cast-confidential-alice", + ), + ) + service.cast_ballot( + session, + Principal("tenant-1", "bob"), + ballot_id=opened.id, + command=VotingCastCommand(("b",), "cast-confidential-bob"), + ) + self.assertEqual(0, session.query(VotingCastRecord).count()) + encrypted = session.query(VotingConfidentialCast).all() + self.assertEqual(2, len(encrypted)) + self.assertTrue(all(row.ciphertext for row in encrypted)) + + result = service.close_ballot( + session, + self.manager, + ballot_id=opened.id, + expected_revision=opened.revision, + idempotency_key="close-confidential", + ) + self.assertEqual({"a": 1, "b": 1}, dict(result.counts)) + self.assertEqual({"a": 2, "b": 1}, dict(result.weighted_counts)) + self.assertEqual("local_confidential", result.evidence[0]["provider_id"]) + self.assertFalse(result.evidence[0]["certified"]) + def test_idempotency_key_cannot_change_command(self) -> None: with self.Session() as session: self.service.create_ballot( diff --git a/webui/src/features/voting/VotingBallotDialog.tsx b/webui/src/features/voting/VotingBallotDialog.tsx index f2364bb..d1af4b8 100644 --- a/webui/src/features/voting/VotingBallotDialog.tsx +++ b/webui/src/features/voting/VotingBallotDialog.tsx @@ -49,7 +49,16 @@ export default function VotingBallotDialog({ && draft.options.every((item) => item.key.trim() && item.label.trim()) && draft.electorate.length > 0 && draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0) - && (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim())) + && ( + draft.assurance_profile === "recorded" + || ( + draft.provider_id?.trim() + && ( + draft.provider_id.trim() === "local_confidential" + || draft.provider_ballot_ref?.trim() + ) + ) + ) ), [draft]); async function save() { @@ -104,7 +113,21 @@ export default function VotingBallotDialog({