feat: add confidential ballot reference provider
This commit is contained in:
@@ -8,8 +8,11 @@ The native `recorded` assurance profile is auditable but not secret: active
|
||||
ballots are stored so that a result can be reconstructed. Confidential,
|
||||
secret, and externally certified profiles fail closed unless an explicit
|
||||
`voting.provider.<provider>` capability is installed. Providers return only
|
||||
aggregate results and evidence; raw secret ballots and provider credentials
|
||||
do not enter the Voting database.
|
||||
aggregate results and evidence through the public Voting boundary. The bundled
|
||||
`local_confidential` provider stores cast selections only as Encryption-owned
|
||||
server-envelope ciphertext in provider-specific shared SQL tables. It is
|
||||
confidential at rest, but it is not anonymous, secret, coercion-resistant, or
|
||||
externally certified.
|
||||
|
||||
Committee and other modules retain their deliberation or business context and
|
||||
refer to Voting ballots through the `voting.ballots` capability. Informal
|
||||
|
||||
@@ -32,6 +32,30 @@ credentials inside its own assurance boundary and returns aggregate counts,
|
||||
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
||||
provider or deployment satisfies legal or certification requirements merely
|
||||
because the adapter contract is implemented.
|
||||
Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields
|
||||
that can carry credentials, private key material, plaintext, or raw
|
||||
selections before Voting or Committee can persist the projection.
|
||||
|
||||
### Bundled local confidential provider
|
||||
|
||||
`local_confidential` is an interactive reference provider for the
|
||||
`confidential` profile. Opening a ballot creates a ballot-scoped Encryption
|
||||
vault using the configured `local_aesgcm` provider. Casting stores the elector,
|
||||
weight, receipt, and replacement generation as provider metadata while the
|
||||
selection payload is stored only as authenticated ciphertext. Finalization
|
||||
opens active cast envelopes inside the provider boundary and exports only the
|
||||
aggregate and sanitized evidence.
|
||||
|
||||
Encryption records a context-bound keyed commitment rather than an enumerable
|
||||
plaintext digest, so low-entropy choices are not exposed to an offline database
|
||||
reader through idempotency metadata.
|
||||
|
||||
This profile is server-readable. It does not provide voter anonymity,
|
||||
unlinkability, coercion resistance, client-held keys, an HSM/KMS claim, or
|
||||
external certification. It therefore cannot be selected for `secret` or
|
||||
`external_certified` ballots. A provider ballot reference is generated when
|
||||
the ballot opens; externally hosted providers may continue to require a
|
||||
pre-existing reference.
|
||||
|
||||
## Lifecycle and concurrency
|
||||
|
||||
@@ -55,6 +79,8 @@ Back up these tables as one consistency unit:
|
||||
- `voting_cast_records`
|
||||
- `voting_lifecycle_events`
|
||||
- `voting_command_replays`
|
||||
- `voting_confidential_ballots`
|
||||
- `voting_confidential_casts`
|
||||
|
||||
A restore is valid only when current revisions are unique per tenant and
|
||||
ballot, cast generations are unique per elector, every active cast references
|
||||
@@ -62,6 +88,10 @@ the current frozen definition hash, result hashes can be recomputed, and event
|
||||
sequences are contiguous. Provider-backed restores also require the external
|
||||
provider evidence and ballot reference to remain resolvable. Do not reopen a
|
||||
restored external ballot merely to compensate for missing provider state.
|
||||
For `local_confidential`, restore the Voting and Encryption tables from the
|
||||
same consistency point and restore the exact deployment `MASTER_KEY_B64` from
|
||||
the approved secret store. A database-only restore or a changed/lost master
|
||||
key must fail closed; it cannot reconstruct confidential casts.
|
||||
|
||||
Before destructive retirement, stop new casts, retain a verified database
|
||||
snapshot, export result/certification evidence under the applicable retention
|
||||
@@ -74,6 +104,8 @@ node-local filesystem.
|
||||
- tenant predicates apply to every ballot, cast, event, and replay lookup
|
||||
- the authenticated account must match the frozen elector subject
|
||||
- native casting fails closed for non-recorded assurance profiles
|
||||
- interactive provider casting validates the frozen tenant, electorate,
|
||||
definition, options, replacement policy, and time window again
|
||||
- raw selections are never returned by list, detail, result, or history APIs
|
||||
- provider result keys must exactly match frozen options
|
||||
- external results require evidence and cannot exceed the frozen electorate
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
+142
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -29,11 +29,13 @@ class VotingMigrationTests(unittest.TestCase):
|
||||
"voting_cast_records",
|
||||
"voting_lifecycle_events",
|
||||
"voting_command_replays",
|
||||
"voting_confidential_ballots",
|
||||
"voting_confidential_casts",
|
||||
}.issubset(tables)
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"7a8b9c0d1e2f",
|
||||
"8b9c0d1e2f3a",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
|
||||
+192
-1
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -11,9 +14,23 @@ from govoplan_core.core.voting import (
|
||||
VotingCastCommand,
|
||||
VotingElector,
|
||||
VotingOption,
|
||||
voting_provider_capability,
|
||||
)
|
||||
from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
ContentProtectionEnvelope,
|
||||
ProtectedContent,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_voting.backend.db.models import VotingCastRecord
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingCastRecord,
|
||||
VotingConfidentialCast,
|
||||
)
|
||||
from govoplan_voting.backend.local_confidential_provider import (
|
||||
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||
LocalConfidentialVotingProvider,
|
||||
)
|
||||
from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
|
||||
|
||||
|
||||
@@ -23,6 +40,87 @@ class Principal:
|
||||
account_id: str
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.capabilities: dict[str, object] = {}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
return self.capabilities.get(name)
|
||||
|
||||
|
||||
class FakeKeyVault:
|
||||
def __init__(self) -> None:
|
||||
self.vaults: dict[tuple[str, str], object] = {}
|
||||
|
||||
def create_vault(self, session, principal, *, request):
|
||||
del session, principal
|
||||
vault = SimpleNamespace(state="active", current_key=SimpleNamespace(version=1))
|
||||
self.vaults[(request.tenant_id, request.vault_id)] = vault
|
||||
return vault
|
||||
|
||||
def get_vault(self, session, *, tenant_id, vault_id):
|
||||
del session
|
||||
return self.vaults.get((tenant_id, vault_id))
|
||||
|
||||
def rotate_key(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revoke_key(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def schedule_destruction(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def reconcile_vault(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class FakeContentCipher:
|
||||
def __init__(self) -> None:
|
||||
self.plaintexts: dict[str, bytes] = {}
|
||||
|
||||
def protect_content(self, session, *, request):
|
||||
del session
|
||||
envelope_id = f"envelope-{request.resource_id}"
|
||||
ciphertext = b"ciphertext:" + hashlib.sha256(request.plaintext).digest()
|
||||
self.plaintexts[envelope_id] = request.plaintext
|
||||
return ProtectedContent(
|
||||
envelope=ContentProtectionEnvelope(
|
||||
envelope_id=envelope_id,
|
||||
tenant_id=request.tenant_id,
|
||||
owner_module=request.owner_module,
|
||||
resource_type=request.resource_type,
|
||||
resource_id=request.resource_id,
|
||||
profile_kind="server_envelope",
|
||||
profile_id=request.profile_id,
|
||||
provider_id="fake",
|
||||
vault_id=request.vault_id,
|
||||
key_version=1,
|
||||
algorithm_suite="AES-256-GCM",
|
||||
ciphertext_ref=request.ciphertext_ref,
|
||||
ciphertext_digest=hashlib.sha256(ciphertext).hexdigest(),
|
||||
authenticated_context_digest="a" * 64,
|
||||
state="active",
|
||||
created_at=datetime.now(UTC),
|
||||
wrapped_key_refs=(f"wrapped-{request.resource_id}",),
|
||||
),
|
||||
ciphertext=ciphertext,
|
||||
)
|
||||
|
||||
def unprotect_content(self, session, *, request):
|
||||
del session
|
||||
return self.plaintexts[request.envelope_id]
|
||||
|
||||
def execute_rewrap(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare_reencryption(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def command(*, assurance: str = "recorded", provider_id: str | None = None):
|
||||
return VotingBallotCreateCommand(
|
||||
title="Budget choice",
|
||||
@@ -192,6 +290,99 @@ class VotingTests(unittest.TestCase):
|
||||
idempotency_key="open-secret",
|
||||
)
|
||||
|
||||
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
||||
self,
|
||||
) -> None:
|
||||
registry = FakeRegistry()
|
||||
provider = LocalConfidentialVotingProvider(registry)
|
||||
registry.capabilities.update(
|
||||
{
|
||||
voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID): provider,
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT: FakeKeyVault(),
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER: FakeContentCipher(),
|
||||
}
|
||||
)
|
||||
service = SqlVotingBallots(registry)
|
||||
draft = replace(
|
||||
command(
|
||||
assurance="confidential",
|
||||
provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||
),
|
||||
provider_ballot_ref=None,
|
||||
)
|
||||
with self.Session() as session:
|
||||
created = service.create_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
command=draft,
|
||||
idempotency_key="create-confidential",
|
||||
)
|
||||
opened = service.open_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=created.id,
|
||||
expected_revision=created.revision,
|
||||
idempotency_key="open-confidential",
|
||||
)
|
||||
detail = service.get_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=opened.id,
|
||||
)
|
||||
self.assertEqual(
|
||||
f"local-confidential:{opened.id}",
|
||||
detail["provider_ballot_ref"],
|
||||
)
|
||||
first = service.cast_ballot(
|
||||
session,
|
||||
Principal("tenant-1", "alice"),
|
||||
ballot_id=opened.id,
|
||||
command=VotingCastCommand(("a",), "cast-confidential-alice"),
|
||||
)
|
||||
replay = service.cast_ballot(
|
||||
session,
|
||||
Principal("tenant-1", "alice"),
|
||||
ballot_id=opened.id,
|
||||
command=VotingCastCommand(("a",), "cast-confidential-alice"),
|
||||
)
|
||||
self.assertEqual(first.receipt_sha256, replay.receipt_sha256)
|
||||
self.assertTrue(replay.replayed)
|
||||
with self.assertRaisesRegex(
|
||||
VotingStoreError,
|
||||
"provider cast was rejected",
|
||||
):
|
||||
service.cast_ballot(
|
||||
session,
|
||||
Principal("tenant-1", "alice"),
|
||||
ballot_id=opened.id,
|
||||
command=VotingCastCommand(
|
||||
("b",),
|
||||
"cast-confidential-alice",
|
||||
),
|
||||
)
|
||||
service.cast_ballot(
|
||||
session,
|
||||
Principal("tenant-1", "bob"),
|
||||
ballot_id=opened.id,
|
||||
command=VotingCastCommand(("b",), "cast-confidential-bob"),
|
||||
)
|
||||
self.assertEqual(0, session.query(VotingCastRecord).count())
|
||||
encrypted = session.query(VotingConfidentialCast).all()
|
||||
self.assertEqual(2, len(encrypted))
|
||||
self.assertTrue(all(row.ciphertext for row in encrypted))
|
||||
|
||||
result = service.close_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=opened.id,
|
||||
expected_revision=opened.revision,
|
||||
idempotency_key="close-confidential",
|
||||
)
|
||||
self.assertEqual({"a": 1, "b": 1}, dict(result.counts))
|
||||
self.assertEqual({"a": 2, "b": 1}, dict(result.weighted_counts))
|
||||
self.assertEqual("local_confidential", result.evidence[0]["provider_id"])
|
||||
self.assertFalse(result.evidence[0]["certified"])
|
||||
|
||||
def test_idempotency_key_cannot_change_command(self) -> None:
|
||||
with self.Session() as session:
|
||||
self.service.create_ballot(
|
||||
|
||||
@@ -49,7 +49,16 @@ export default function VotingBallotDialog({
|
||||
&& draft.options.every((item) => item.key.trim() && item.label.trim())
|
||||
&& draft.electorate.length > 0
|
||||
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0)
|
||||
&& (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim()))
|
||||
&& (
|
||||
draft.assurance_profile === "recorded"
|
||||
|| (
|
||||
draft.provider_id?.trim()
|
||||
&& (
|
||||
draft.provider_id.trim() === "local_confidential"
|
||||
|| draft.provider_ballot_ref?.trim()
|
||||
)
|
||||
)
|
||||
)
|
||||
), [draft]);
|
||||
|
||||
async function save() {
|
||||
@@ -104,7 +113,21 @@ export default function VotingBallotDialog({
|
||||
</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">
|
||||
<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="confidential">Confidential 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>
|
||||
{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 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user