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
+5 -2
View File
@@ -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, ballots are stored so that a result can be reconstructed. Confidential,
secret, and externally certified profiles fail closed unless an explicit secret, and externally certified profiles fail closed unless an explicit
`voting.provider.<provider>` capability is installed. Providers return only `voting.provider.<provider>` capability is installed. Providers return only
aggregate results and evidence; raw secret ballots and provider credentials aggregate results and evidence through the public Voting boundary. The bundled
do not enter the Voting database. `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 Committee and other modules retain their deliberation or business context and
refer to Voting ballots through the `voting.ballots` capability. Informal refer to Voting ballots through the `voting.ballots` capability. Informal
+32
View File
@@ -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 weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
provider or deployment satisfies legal or certification requirements merely provider or deployment satisfies legal or certification requirements merely
because the adapter contract is implemented. 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 ## Lifecycle and concurrency
@@ -55,6 +79,8 @@ Back up these tables as one consistency unit:
- `voting_cast_records` - `voting_cast_records`
- `voting_lifecycle_events` - `voting_lifecycle_events`
- `voting_command_replays` - `voting_command_replays`
- `voting_confidential_ballots`
- `voting_confidential_casts`
A restore is valid only when current revisions are unique per tenant and A restore is valid only when current revisions are unique per tenant and
ballot, cast generations are unique per elector, every active cast references 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 sequences are contiguous. Provider-backed restores also require the external
provider evidence and ballot reference to remain resolvable. Do not reopen a provider evidence and ballot reference to remain resolvable. Do not reopen a
restored external ballot merely to compensate for missing provider state. 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 Before destructive retirement, stop new casts, retain a verified database
snapshot, export result/certification evidence under the applicable retention 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 - tenant predicates apply to every ballot, cast, event, and replay lookup
- the authenticated account must match the frozen elector subject - the authenticated account must match the frozen elector subject
- native casting fails closed for non-recorded assurance profiles - 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 - raw selections are never returned by list, detail, result, or history APIs
- provider result keys must exactly match frozen options - provider result keys must exactly match frozen options
- external results require evidence and cannot exceed the frozen electorate - external results require evidence and cannot exceed the frozen electorate
+119
View File
@@ -5,11 +5,13 @@ from typing import Any
import uuid import uuid
from sqlalchemy import ( from sqlalchemy import (
Boolean,
DateTime, DateTime,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
JSON, JSON,
LargeBinary,
String, String,
UniqueConstraint, UniqueConstraint,
) )
@@ -145,9 +147,126 @@ class VotingCommandReplay(Base, TimestampMixin):
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) 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__ = [ __all__ = [
"VotingBallotRevision", "VotingBallotRevision",
"VotingCastRecord", "VotingCastRecord",
"VotingCommandReplay", "VotingCommandReplay",
"VotingConfidentialBallot",
"VotingConfidentialCast",
"VotingLifecycleEvent", "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_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, 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 ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
@@ -19,6 +23,7 @@ from govoplan_core.core.modules import (
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, 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.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface 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_core.db.base import Base
from govoplan_voting.backend.db import models as voting_models from govoplan_voting.backend.db import models as voting_models
from govoplan_voting.backend.service import SqlVotingBallots from govoplan_voting.backend.service import SqlVotingBallots
from govoplan_voting.backend.local_confidential_provider import (
LOCAL_CONFIDENTIAL_PROVIDER_ID,
LocalConfidentialVotingProvider,
)
MODULE_ID = "voting" MODULE_ID = "voting"
@@ -67,6 +79,12 @@ def _ballots(context: ModuleContext) -> SqlVotingBallots:
return SqlVotingBallots(context.registry) 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]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(voting_models.VotingBallotRevision).filter( current = session.query(voting_models.VotingBallotRevision).filter(
voting_models.VotingBallotRevision.tenant_id == tenant_id, voting_models.VotingBallotRevision.tenant_id == tenant_id,
@@ -101,6 +119,24 @@ manifest = ModuleManifest(
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"), 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=( permissions=(
_permission( _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_documentation={
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation( CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
label="Governed ballots", label="Governed ballots",
@@ -226,6 +267,8 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
voting_models.VotingCommandReplay, voting_models.VotingCommandReplay,
voting_models.VotingLifecycleEvent, voting_models.VotingLifecycleEvent,
voting_models.VotingConfidentialCast,
voting_models.VotingConfidentialBallot,
voting_models.VotingCastRecord, voting_models.VotingCastRecord,
voting_models.VotingBallotRevision, voting_models.VotingBallotRevision,
label=MODULE_NAME, label=MODULE_NAME,
@@ -238,6 +281,8 @@ manifest = ModuleManifest(
voting_models.VotingCastRecord, voting_models.VotingCastRecord,
voting_models.VotingLifecycleEvent, voting_models.VotingLifecycleEvent,
voting_models.VotingCommandReplay, voting_models.VotingCommandReplay,
voting_models.VotingConfidentialBallot,
voting_models.VotingConfidentialCast,
label=MODULE_NAME, label=MODULE_NAME,
), ),
), ),
@@ -250,6 +295,7 @@ manifest = ModuleManifest(
body=( body=(
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. " "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. " "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." "Closure, certification, challenge, and annulment remain separate auditable transitions."
), ),
layer="configured", layer="configured",
@@ -273,6 +319,7 @@ manifest = ModuleManifest(
known_limits=( known_limits=(
"The native profile is recorded and reconstructable, not cryptographically secret.", "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.", "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.", "Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
), ),
supported_authority_modes=("native_authoritative", "external_authoritative"), 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")
+151 -17
View File
@@ -12,8 +12,11 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.voting import ( from govoplan_core.core.voting import (
ExternalVotingCastRequest,
ExternalVotingFinalizationRequest, ExternalVotingFinalizationRequest,
ExternalVotingPreparationRequest,
ExternalVotingProvider, ExternalVotingProvider,
InteractiveExternalVotingProvider,
VOTING_ASSURANCE_RECORDED, VOTING_ASSURANCE_RECORDED,
VotingBallotCreateCommand, VotingBallotCreateCommand,
VotingBallotRef, VotingBallotRef,
@@ -250,19 +253,81 @@ class SqlVotingBallots:
payload = dict(current.payload) payload = dict(current.payload)
_validate_draft(payload) _validate_draft(payload)
profile = str(payload["assurance_profile"]) profile = str(payload["assurance_profile"])
provider: object | None = None
if profile != VOTING_ASSURANCE_RECORDED: if profile != VOTING_ASSURANCE_RECORDED:
provider_id = str(payload.get("provider_id") or "").strip() provider_id = str(payload.get("provider_id") or "").strip()
if ( provider = (
not provider_id _capability(self._registry, voting_provider_capability(provider_id))
or _capability(self._registry, voting_provider_capability(provider_id)) if provider_id
is None else None
): )
if not isinstance(provider, ExternalVotingProvider):
raise VotingStoreError( raise VotingStoreError(
"The selected Voting assurance profile requires an available external provider." "The selected Voting assurance profile requires an available external provider."
) )
definition_hash, electorate_hash = _frozen_hashes(payload) definition_hash, electorate_hash = _frozen_hashes(payload)
payload["definition_sha256"] = definition_hash payload["definition_sha256"] = definition_hash
payload["electorate_sha256"] = electorate_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( revised = _revise(
typed_session, typed_session,
current=current, current=current,
@@ -276,6 +341,9 @@ class SqlVotingBallots:
"revision": current.revision + 1, "revision": current.revision + 1,
"definition_sha256": definition_hash, "definition_sha256": definition_hash,
"electorate_sha256": electorate_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) result = _ref(revised)
@@ -301,10 +369,6 @@ class SqlVotingBallots:
current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) current = _current(typed_session, principal, ballot_id=ballot_id, lock=True)
if current.state != "open": if current.state != "open":
raise VotingStoreError("Only an open Voting ballot accepts votes.") 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) _validate_window(current.payload)
actor_id = _principal_actor(principal) actor_id = _principal_actor(principal)
elector_id = str(command.elector_id or actor_id or "").strip() elector_id = str(command.elector_id or actor_id or "").strip()
@@ -328,6 +392,63 @@ class SqlVotingBallots:
) )
_validate_selections(current.payload, selections) _validate_selections(current.payload, selections)
idempotency_key = _idempotency(command.idempotency_key) 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 = ( replay = (
typed_session.query(VotingCastRecord) typed_session.query(VotingCastRecord)
.filter( .filter(
@@ -743,6 +864,7 @@ class SqlVotingBallots:
if not isinstance(provider, ExternalVotingProvider): if not isinstance(provider, ExternalVotingProvider):
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.") raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
electorate = list(current.payload["electorate"]) electorate = list(current.payload["electorate"])
try:
result = provider.finalize_ballot( result = provider.finalize_ballot(
session, session,
principal, principal,
@@ -753,7 +875,8 @@ class SqlVotingBallots:
definition_sha256=str(current.definition_sha256), definition_sha256=str(current.definition_sha256),
electorate_sha256=str(current.electorate_sha256), electorate_sha256=str(current.electorate_sha256),
options=tuple( options=tuple(
_option_from_mapping(item) for item in current.payload["options"] _option_from_mapping(item)
for item in current.payload["options"]
), ),
eligible_count=len(electorate), eligible_count=len(electorate),
eligible_weight=sum( eligible_weight=sum(
@@ -763,6 +886,10 @@ class SqlVotingBallots:
idempotency_key=_idempotency(idempotency_key), 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"]} option_keys = {str(item["key"]) for item in current.payload["options"]}
if result.ballot_id != current.ballot_id: if result.ballot_id != current.ballot_id:
raise VotingStoreError( 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: if opens_at and closes_at and closes_at <= opens_at:
raise VotingStoreError("Voting close time must be after its open time.") raise VotingStoreError("Voting close time must be after its open time.")
if assurance != VOTING_ASSURANCE_RECORDED: if assurance != VOTING_ASSURANCE_RECORDED:
if ( if not str(payload.get("provider_id") or "").strip():
not str(payload.get("provider_id") or "").strip() raise VotingStoreError("External Voting assurance requires a provider id.")
or not str(payload.get("provider_ballot_ref") or "").strip()
):
raise VotingStoreError(
"External Voting assurance requires provider id and ballot reference."
)
def _validate_selections( def _validate_selections(
@@ -919,6 +1041,7 @@ def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
"definition_sha256", "definition_sha256",
"result", "result",
"certification", "certification",
"provider_ballot_ref",
} }
} }
return _sha256(definition), _sha256(electorate) 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: def _sha256(value: object) -> str:
encoded = json.dumps( encoded = json.dumps(
value, value,
+3 -1
View File
@@ -29,11 +29,13 @@ class VotingMigrationTests(unittest.TestCase):
"voting_cast_records", "voting_cast_records",
"voting_lifecycle_events", "voting_lifecycle_events",
"voting_command_replays", "voting_command_replays",
"voting_confidential_ballots",
"voting_confidential_casts",
}.issubset(tables) }.issubset(tables)
) )
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"7a8b9c0d1e2f", "8b9c0d1e2f3a",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
finally: finally:
+192 -1
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import UTC, datetime
import hashlib
from types import SimpleNamespace
import unittest import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -11,9 +14,23 @@ from govoplan_core.core.voting import (
VotingCastCommand, VotingCastCommand,
VotingElector, VotingElector,
VotingOption, 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_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 from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
@@ -23,6 +40,87 @@ class Principal:
account_id: str 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): def command(*, assurance: str = "recorded", provider_id: str | None = None):
return VotingBallotCreateCommand( return VotingBallotCreateCommand(
title="Budget choice", title="Budget choice",
@@ -192,6 +290,99 @@ class VotingTests(unittest.TestCase):
idempotency_key="open-secret", 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: def test_idempotency_key_cannot_change_command(self) -> None:
with self.Session() as session: with self.Session() as session:
self.service.create_ballot( self.service.create_ballot(
@@ -49,7 +49,16 @@ export default function VotingBallotDialog({
&& draft.options.every((item) => item.key.trim() && item.label.trim()) && draft.options.every((item) => item.key.trim() && item.label.trim())
&& draft.electorate.length > 0 && draft.electorate.length > 0
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 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]); ), [draft]);
async function save() { async function save() {
@@ -104,7 +113,21 @@ export default function VotingBallotDialog({
</FormField> </FormField>
<FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="Assurance profile"> <FormField label="Assurance profile">
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => setDraft({ ...draft, assurance_profile: event.target.value as VotingBallotDraft["assurance_profile"] })}> <select value={draft.assurance_profile} disabled={busy} onChange={(event) => {
const assurance_profile = event.target.value as VotingBallotDraft["assurance_profile"];
setDraft({
...draft,
assurance_profile,
provider_id: assurance_profile === "recorded"
? null
: assurance_profile === "confidential"
? "local_confidential"
: draft.provider_id === "local_confidential" ? "" : draft.provider_id,
provider_ballot_ref: assurance_profile === "recorded" || assurance_profile === "confidential"
? null
: draft.provider_ballot_ref
});
}}>
<option value="recorded">Recorded</option> <option value="recorded">Recorded</option>
<option value="confidential">Confidential provider</option> <option value="confidential">Confidential provider</option>
<option value="secret">Secret provider</option> <option value="secret">Secret provider</option>
@@ -117,7 +140,14 @@ export default function VotingBallotDialog({
<div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div> <div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div>
{draft.assurance_profile !== "recorded" && <> {draft.assurance_profile !== "recorded" && <>
<FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField> <FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
<FormField label="Provider ballot reference"><input value={draft.provider_ballot_ref ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })} /></FormField> <FormField label="Provider ballot reference">
<input
value={draft.provider_ballot_ref ?? ""}
placeholder={draft.provider_id === "local_confidential" ? "Generated when opened" : undefined}
disabled={busy || draft.provider_id === "local_confidential"}
onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })}
/>
</FormField>
</>} </>}
</div> </div>