feat: add confidential ballot reference provider

This commit is contained in:
2026-08-02 03:41:01 +02:00
parent c176c5cfcb
commit 0a4e06077d
10 changed files with 1505 additions and 44 deletions
+119
View File
@@ -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",
]
@@ -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",
]
+49 -2
View File
@@ -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"),
@@ -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")
+169 -35
View File
@@ -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,