diff --git a/README.md b/README.md index 4feb6c7..4961851 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,9 @@ preference and availability collection remains in `govoplan-poll`. See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations, security, recovery, and integration details. + +POLYAS is the first planned external provider; its bounded operator-assisted +profile and integration prerequisites are documented in +[the POLYAS provider profile](docs/POLYAS_PROVIDER_PROFILE.md). Development of +a native certifiable provider follows the staged, independently evaluated +[certifiable Voting program](docs/CERTIFIABLE_VOTING_PROGRAM.md). diff --git a/docs/CERTIFIABLE_VOTING_PROGRAM.md b/docs/CERTIFIABLE_VOTING_PROGRAM.md new file mode 100644 index 0000000..c5a6377 --- /dev/null +++ b/docs/CERTIFIABLE_VOTING_PROGRAM.md @@ -0,0 +1,94 @@ +# Native certifiable Voting program + +## Objective and non-claim + +GovOPlaN may develop a native end-to-end verifiable Voting provider, but the +current platform and bundled `local_confidential` provider are not certified +voting products. Certification cannot be obtained by adding a label, tests, or +general platform security controls. It applies to a precisely bounded Target +of Evaluation (TOE), version, evaluated configuration, lifecycle, and evidence +set assessed by an independent laboratory and certification authority. + +The native provider must therefore be an isolated assurance component behind +`voting.provider.`, not an implicit claim over all of GovOPlaN. Voting owns +the governed ballot lifecycle and evidence projection; the TOE owns ballot +secrecy, cryptographic casting, verification, tallying, and the evaluated +ceremony. Policy, Access, Identity Trust, Encryption, Forms Runtime, Workflow +Engine, Committee, Decisions, Audit, Records, and Reporting may support the +journey without being silently pulled into the TOE. + +## Program stages + +### 1. Protection profile and legal target + +- identify election classes, jurisdictions, attack potential, voting + principles, accessibility duties, and retention obligations; +- select the applicable BSI Protection Profile/TR and Common Criteria target; +- engage a recognized evaluation facility before fixing the architecture; +- write the Security Target, assumptions, threats, organizational policies, + security objectives, and evaluated configuration. + +### 2. TOE and trust boundaries + +- specify client, election server, bulletin board, verifier, tally component, + key ceremony, build/release chain, time source, and operator boundaries; +- define electorate preparation and archival as explicit supporting processes + when they are outside the TOE; +- prohibit node-local authoritative state and undeclared side channels; +- define compromise, suspension, challenge, annulment, recovery, and evidence + export before implementation. + +### 3. Protocol and independent review + +- select a published, independently reviewed end-to-end verifiable protocol; +- use reviewed cryptographic libraries and parameter suites rather than + designing new cryptography; +- provide individual and universal verification without exposing vote choice; +- define coercion-resistance claims truthfully, including what is not solved; +- commission independent cryptographic and privacy review before production. + +### 4. Conformance implementation + +- implement canonical ballot/electorate/result/evidence encodings; +- bind every cast and tally artifact to the frozen definition and electorate; +- provide deterministic conformance fixtures, malformed-input suites, + property tests, fault injection, and cross-implementation verification; +- preserve receipt privacy and prevent credentials, raw votes, or private keys + from entering GovOPlaN evidence projections; +- expose certification state through `VotingProviderAssuranceDeclaration`. + +### 5. Controlled lifecycle + +- reproducible, signed builds and reviewed dependencies; +- role-separated source, release, election, key-custody, and audit authority; +- vulnerability handling, maintenance impact analysis, SBOM, provenance, and + controlled update path for in-progress elections; +- production ceremonies, backup/restore, disaster recovery, secure deletion, + monitoring, incident response, and independently witnessed evidence. + +### 6. Evaluation and operation + +- laboratory pre-evaluation and gap remediation; +- formal Common Criteria evaluation/certification of an exact TOE version; +- target-specific deployment acceptance against the evaluated configuration; +- certificate and maintenance-report monitoring; +- fail-closed retirement or profile downgrade when validity expires or the + evaluated configuration changes. + +## Work-product gates + +Native implementation can proceed through fixtures and research profiles, but +the `external_certified` runtime profile remains unavailable until all of these +are independently evidenced: + +- approved Security Target and TOE boundary; +- independent protocol/cryptographic review; +- conformance and adverse-condition evidence; +- controlled build and release provenance; +- operational ceremony and recovery evidence; +- valid product/version/configuration-specific certificate. + +Research, evaluation, and certified states are separate. A provider in +evaluation may support a bounded test profile, but cannot become certified by +configuration or administrator override. + diff --git a/docs/POLYAS_PROVIDER_PROFILE.md b/docs/POLYAS_PROVIDER_PROFILE.md new file mode 100644 index 0000000..280a46b --- /dev/null +++ b/docs/POLYAS_PROVIDER_PROFILE.md @@ -0,0 +1,80 @@ +# POLYAS provider profile + +## Current integration position + +POLYAS is the first external provider selected for high-assurance GovOPlaN +Voting. This is an integration decision, not a certification claim. Until a +contracted machine interface, sandbox, exact product/version binding, and +current certificate evidence are available, the integration remains +operator-assisted and must not advertise the `external_certified` assurance +profile. + +The public POLYAS material documents the Online Voting Manager, spreadsheet +electoral-roll import, PDF/Excel result export, an election control portal, +verification tools, SecureLink, and an electoral-board interface. It does not +document a stable public API that is sufficient for an unattended GovOPlaN +provider. The initial integration therefore uses the existing external +provider contract as its target and keeps manual handoffs explicit: + +1. GovOPlaN freezes the ballot definition and electorate hashes. +2. An authorized election officer creates and seals the corresponding POLYAS + election using a reviewed export. +3. GovOPlaN records the POLYAS project reference, exact product/profile, and + handoff evidence without storing voter credentials. +4. Voters enter the provider through its controlled launch or invitation path. +5. An authorized officer imports signed result and protocol artifacts. +6. GovOPlaN verifies the frozen binding, records aggregate results and evidence, + and retains certification, challenge, and annulment as separate actions. + +Operator-assisted imports must be labelled as such. Browser automation or +screen scraping is not an acceptable production API. + +## Provider information required + +Before implementing unattended preparation, launch, status, or result +acquisition, obtain from POLYAS: + +- the contracted API/protocol specification and versioning policy; +- sandbox credentials and representative test-election fixtures; +- supported ballot methods, weighting, voter groups, replacement, quorum, and + threshold semantics; +- idempotency, revision, sealing, cancellation, outcome-unknown, and retry + behavior; +- invitation and voter-authentication boundaries; +- signed result, archive, audit, and verification artifact formats; +- retention, deletion, subprocessor, location, incident, and DPA terms; +- product/version-specific Security Target, certificate, maintenance reports, + validity period, and evaluated configuration; +- recovery and continuity evidence for an election in progress. + +Credentials belong in governed credential envelopes. Raw selections, voter +credentials, recovery codes, and private provider keys must never cross the +Voting provider boundary. + +## Certification gate + +The BSI certificate `BSI-DSZ-CC-0862-V2-2021` for POLYAS CORE 2.5.0, including +maintained versions described by its maintenance reports, was valid through +2026-06-24. As of 2026-08-04, that validity date has passed. A new election must +not be labelled `external_certified` from this historical certificate alone. + +The adapter must expose a `VotingProviderAssuranceDeclaration`. The runtime +accepts `external_certified` only when the declaration pins: + +- the exact provider and implementation contract; +- supported assurance profile and protocol version; +- certification authority and reference; +- an independently retrievable evidence reference; +- a current validity window. + +The declaration is frozen with the ballot and revalidated before cast and +finalization. Expiry, revocation, provider replacement, protocol change, or +certificate substitution fails closed and requires explicit reconciliation. + +Authoritative references: + +- [BSI certificate record](https://www.bsi.bund.de/SharedDocs/Zertifikate_CC/CC/Sonstiges/0862_0862V2.html) +- [BSI TR-03169](https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03169/BSI-TR-03169.pdf) +- [POLYAS security overview](https://support.polyas.com/en/faqs/security/ensure-secure-voting/) +- [POLYAS election control portal](https://support.polyas.com/en/online-voting-manager/features/authentication/election-control-portal/) + diff --git a/docs/VOTING_DOMAIN.md b/docs/VOTING_DOMAIN.md index f445c85..6ee28f5 100644 --- a/docs/VOTING_DOMAIN.md +++ b/docs/VOTING_DOMAIN.md @@ -32,6 +32,12 @@ 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. +Each provider declares supported assurance profiles, protocol and +implementation identity, and certification state. Voting pins that declaration +when opening and revalidates it before provider casting and finalization. +`external_certified` requires a current authority, certificate reference, +evidence reference, and validity window; a changed, expired, or revoked claim +fails closed. 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. @@ -57,6 +63,14 @@ external certification. It therefore cannot be selected for `secret` or the ballot opens; externally hosted providers may continue to require a pre-existing reference. +POLYAS is the selected first external provider, initially through an explicit +operator-assisted handoff until a contracted API and sandbox are available. +The historical POLYAS CORE 2.5 Common Criteria certificate expired on +2026-06-24, so its reference alone cannot enable `external_certified`. See +[the POLYAS provider profile](POLYAS_PROVIDER_PROFILE.md). Native certifiable +development is governed by the separate +[certifiable Voting program](CERTIFIABLE_VOTING_PROGRAM.md). + ## Lifecycle and concurrency Ballots move through `draft -> open -> closed -> certified`. A closed or @@ -109,4 +123,7 @@ node-local filesystem. - 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 +- external providers must match the assurance declaration frozen at opening +- externally certified providers must remain currently certified through + provider casting and finalization - certification and annulment use separate permissions diff --git a/src/govoplan_voting/backend/local_confidential_provider.py b/src/govoplan_voting/backend/local_confidential_provider.py index 8f3a217..dc18d7b 100644 --- a/src/govoplan_voting/backend/local_confidential_provider.py +++ b/src/govoplan_voting/backend/local_confidential_provider.py @@ -21,8 +21,10 @@ from govoplan_core.core.voting import ( ExternalVotingCastRequest, ExternalVotingFinalizationRequest, ExternalVotingPreparationRequest, + VOTING_CERTIFICATION_NOT_CERTIFIED, VotingReceipt, VotingResult, + VotingProviderAssuranceDeclaration, ) from govoplan_voting.backend.db.models import ( VotingConfidentialBallot, @@ -49,6 +51,19 @@ class LocalConfidentialVotingProvider: def __init__(self, registry: object | None) -> None: self._registry = registry + def assurance_declaration(self) -> VotingProviderAssuranceDeclaration: + return VotingProviderAssuranceDeclaration( + provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID, + implementation_ref="govoplan-voting/local-confidential@1", + supported_assurance_profiles=("confidential",), + certification_state=VOTING_CERTIFICATION_NOT_CERTIFIED, + protocol_ref="govoplan:voting:local-confidential", + protocol_version="1.0", + notes=( + "Server-readable reference provider; no anonymity, secrecy, coercion-resistance, or certification claim.", + ), + ) + def prepare_ballot( self, session: object, diff --git a/src/govoplan_voting/backend/manifest.py b/src/govoplan_voting/backend/manifest.py index eb9b2dd..ff520bc 100644 --- a/src/govoplan_voting/backend/manifest.py +++ b/src/govoplan_voting/backend/manifest.py @@ -296,6 +296,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. " + "Provider assurance, protocol identity, certificate evidence, and validity are pinned when the ballot opens and revalidated before provider effects. " "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." ), @@ -308,6 +309,16 @@ manifest = ModuleManifest( href="govoplan-voting/docs/VOTING_DOMAIN.md", kind="repository", ), + DocumentationLink( + label="POLYAS provider profile", + href="govoplan-voting/docs/POLYAS_PROVIDER_PROFILE.md", + kind="repository", + ), + DocumentationLink( + label="Native certifiable Voting program", + href="govoplan-voting/docs/CERTIFIABLE_VOTING_PROGRAM.md", + kind="repository", + ), ), metadata={ "seed": True, @@ -382,6 +393,7 @@ manifest = ModuleManifest( "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.", + "POLYAS remains an operator-assisted integration target until a contracted API, sandbox, current certification evidence, and conformance fixtures are available.", "Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.", ), supported_authority_modes=("native_authoritative", "external_authoritative"), @@ -402,7 +414,11 @@ manifest = ModuleManifest( reference_packages=("product.service-to-decision",), migration_docs=("docs/VOTING_DOMAIN.md",), recovery_docs=("docs/VOTING_DOMAIN.md",), - security_docs=("docs/VOTING_DOMAIN.md",), + security_docs=( + "docs/VOTING_DOMAIN.md", + "docs/POLYAS_PROVIDER_PROFILE.md", + "docs/CERTIFIABLE_VOTING_PROGRAM.md", + ), operations_docs=("docs/VOTING_DOMAIN.md",), ), ) diff --git a/src/govoplan_voting/backend/service.py b/src/govoplan_voting/backend/service.py index 130b239..c7d6f13 100644 --- a/src/govoplan_voting/backend/service.py +++ b/src/govoplan_voting/backend/service.py @@ -21,8 +21,10 @@ from govoplan_core.core.voting import ( VotingBallotCreateCommand, VotingBallotRef, VotingCastCommand, + VotingCapabilityError, VotingReceipt, VotingResult, + require_voting_provider_assurance, voting_provider_capability, ) from govoplan_voting.backend.db.models import ( @@ -265,6 +267,16 @@ class SqlVotingBallots: raise VotingStoreError( "The selected Voting assurance profile requires an available external provider." ) + try: + declaration = require_voting_provider_assurance( + provider, + provider_id=provider_id, + assurance_profile=profile, + at=_now(), + ) + except VotingCapabilityError as exc: + raise VotingStoreError(str(exc)) from exc + payload["provider_assurance"] = declaration.to_dict() definition_hash, electorate_hash = _frozen_hashes(payload) payload["definition_sha256"] = definition_hash payload["electorate_sha256"] = electorate_hash @@ -343,6 +355,7 @@ class SqlVotingBallots: "electorate_sha256": electorate_hash, "provider_id": payload.get("provider_id"), "provider_ballot_ref": payload.get("provider_ballot_ref"), + "provider_assurance": payload.get("provider_assurance"), "provider_evidence": [dict(item) for item in provider_evidence], }, ) @@ -403,6 +416,7 @@ class SqlVotingBallots: raise VotingStoreError( "This Voting provider does not expose an interactive cast capability." ) + _require_pinned_provider_assurance(current.payload, provider) try: receipt = provider.cast_ballot( typed_session, @@ -863,6 +877,7 @@ class SqlVotingBallots: provider = _capability(self._registry, voting_provider_capability(provider_id)) if not isinstance(provider, ExternalVotingProvider): raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.") + _require_pinned_provider_assurance(current.payload, provider) electorate = list(current.payload["electorate"]) try: result = provider.finalize_ballot( @@ -944,6 +959,7 @@ def _payload_from_command(command: VotingBallotCreateCommand) -> dict[str, Any]: "closes_at": _datetime_text(command.closes_at), "provider_id": _optional_text(command.provider_id), "provider_ballot_ref": _optional_text(command.provider_ballot_ref), + "provider_assurance": None, "metadata": dict(command.metadata), "definition_sha256": None, "electorate_sha256": None, @@ -1029,6 +1045,28 @@ def _validate_window(payload: Mapping[str, Any]) -> None: raise VotingStoreError("Voting ballot has already reached its close time.") +def _require_pinned_provider_assurance( + payload: Mapping[str, Any], + provider: object, +) -> None: + provider_id = str(payload.get("provider_id") or "").strip() + assurance_profile = str(payload.get("assurance_profile") or "").strip() + try: + current = require_voting_provider_assurance( + provider, + provider_id=provider_id, + assurance_profile=assurance_profile, + at=_now(), + ) + except VotingCapabilityError as exc: + raise VotingStoreError(str(exc)) from exc + pinned = payload.get("provider_assurance") + if not isinstance(pinned, Mapping) or dict(pinned) != current.to_dict(): + raise VotingStoreError( + "Voting provider assurance changed after the ballot was frozen." + ) + + def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]: electorate = list(payload.get("electorate") or []) definition = { diff --git a/tests/test_voting.py b/tests/test_voting.py index 2706797..289088d 100644 --- a/tests/test_voting.py +++ b/tests/test_voting.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import hashlib from types import SimpleNamespace import unittest @@ -10,10 +10,13 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_core.core.voting import ( + VOTING_CERTIFICATION_CERTIFIED, + VOTING_CERTIFICATION_IN_EVALUATION, VotingBallotCreateCommand, VotingCastCommand, VotingElector, VotingOption, + VotingProviderAssuranceDeclaration, voting_provider_capability, ) from govoplan_core.core.encryption import ( @@ -51,6 +54,17 @@ class FakeRegistry: return self.capabilities.get(name) +class FakeExternalVotingProvider: + def __init__(self, declaration: VotingProviderAssuranceDeclaration) -> None: + self.declaration = declaration + + def assurance_declaration(self) -> VotingProviderAssuranceDeclaration: + return self.declaration + + def finalize_ballot(self, session, principal, *, request): + raise AssertionError("finalization should not run in assurance gate tests") + + class FakeKeyVault: def __init__(self) -> None: self.vaults: dict[tuple[str, str], object] = {} @@ -290,6 +304,95 @@ class VotingTests(unittest.TestCase): idempotency_key="open-secret", ) + def test_external_certified_provider_claim_is_current_and_frozen(self) -> None: + now = datetime.now(UTC) + candidate = FakeExternalVotingProvider( + VotingProviderAssuranceDeclaration( + provider_id="certified", + implementation_ref="vendor/adapter@1", + supported_assurance_profiles=("external_certified",), + certification_state=VOTING_CERTIFICATION_IN_EVALUATION, + protocol_ref="vendor:ballot", + protocol_version="3.0", + ) + ) + registry = FakeRegistry() + registry.capabilities[voting_provider_capability("certified")] = candidate + service = SqlVotingBallots(registry) + + with self.Session() as session: + created = service.create_ballot( + session, + self.manager, + command=command( + assurance="external_certified", + provider_id="certified", + ), + idempotency_key="create-certified-candidate", + ) + with self.assertRaisesRegex(VotingStoreError, "currently valid"): + service.open_ballot( + session, + self.manager, + ballot_id=created.id, + expected_revision=created.revision, + idempotency_key="open-certified-candidate", + ) + + candidate.declaration = VotingProviderAssuranceDeclaration( + provider_id="certified", + implementation_ref="vendor/adapter@1", + supported_assurance_profiles=("external_certified",), + certification_state=VOTING_CERTIFICATION_CERTIFIED, + protocol_ref="vendor:ballot", + protocol_version="3.0", + certification_authority="Independent authority", + certification_reference="certificate-2026-1", + certification_evidence_ref="evidence://certificate-2026-1", + certification_valid_from=now - timedelta(days=1), + certification_valid_until=now + timedelta(days=1), + ) + with self.Session() as session: + created = service.create_ballot( + session, + self.manager, + command=command( + assurance="external_certified", + provider_id="certified", + ), + idempotency_key="create-certified", + ) + opened = service.open_ballot( + session, + self.manager, + ballot_id=created.id, + expected_revision=created.revision, + idempotency_key="open-certified", + ) + detail = service.get_ballot( + session, + self.manager, + ballot_id=created.id, + ) + self.assertEqual( + "certificate-2026-1", + detail["provider_assurance"]["certification_reference"], + ) + + candidate.declaration = replace( + candidate.declaration, + certification_reference="certificate-2026-2", + certification_evidence_ref="evidence://certificate-2026-2", + ) + with self.assertRaisesRegex(VotingStoreError, "changed after"): + service.close_ballot( + session, + self.manager, + ballot_id=opened.id, + expected_revision=opened.revision, + idempotency_key="close-certified", + ) + def test_local_confidential_provider_encrypts_casts_and_returns_aggregates( self, ) -> None: