feat: finalize encryption and voting provider contracts
This commit is contained in:
@@ -36,7 +36,7 @@ set +a
|
|||||||
| Setting | Required outside dev | Purpose |
|
| Setting | Required outside dev | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
||||||
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
|
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
|
||||||
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
||||||
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
|
|||||||
## Operator Checklist
|
## Operator Checklist
|
||||||
|
|
||||||
- Runtime secrets are injected outside git.
|
- Runtime secrets are injected outside git.
|
||||||
- `MASTER_KEY_B64` is set and backed up securely.
|
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
|
||||||
- Database backup and restore commands are tested.
|
- Database backup and restore commands are tested.
|
||||||
- File/object storage is durable and backed up.
|
- File/object storage is durable and backed up.
|
||||||
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ from datetime import datetime
|
|||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_ENCRYPTION_KEY_VAULT = "encryption.keyVault"
|
CAPABILITY_ENCRYPTION_KEY_VAULT = "encryption.key_vault"
|
||||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION = "encryption.contentProtection"
|
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION = "encryption.content_protection"
|
||||||
CAPABILITY_ENCRYPTION_RECOVERY = "encryption.recoveryCeremony"
|
CAPABILITY_ENCRYPTION_CONTENT_CIPHER = "encryption.content_cipher"
|
||||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT = "encryption.disablePreflight"
|
CAPABILITY_ENCRYPTION_RECOVERY = "encryption.recovery_ceremony"
|
||||||
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX = "encryption.keyMaterialProvider."
|
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT = "encryption.disable_preflight"
|
||||||
|
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX = "encryption.key_material_provider."
|
||||||
|
CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX = (
|
||||||
|
"encryption.content_cipher_provider."
|
||||||
|
)
|
||||||
ENCRYPTION_CONTRACT_VERSION = "1"
|
ENCRYPTION_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
ProtectionProfileKind = Literal[
|
ProtectionProfileKind = Literal[
|
||||||
@@ -271,6 +275,220 @@ class ContentProtectionEnvelope:
|
|||||||
_reject_secret_mapping(self.metadata, "Protection metadata")
|
_reject_secret_mapping(self.metadata, "Protection metadata")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentCipherEncryptRequest:
|
||||||
|
"""Provider request whose key material remains behind the provider boundary."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
key_version: int
|
||||||
|
provider_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
plaintext: bytes = field(repr=False)
|
||||||
|
authenticated_context: bytes = field(repr=False)
|
||||||
|
idempotency_key: str
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_version < 1:
|
||||||
|
raise ValueError("Content cipher key version must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("provider key reference", self.provider_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
if not self.authenticated_context:
|
||||||
|
raise ValueError("Authenticated context is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentCipherEncryptResult:
|
||||||
|
provider_id: str
|
||||||
|
provider_key_ref: str
|
||||||
|
wrapped_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
ciphertext: bytes = field(repr=False)
|
||||||
|
ciphertext_digest: str
|
||||||
|
authenticated_context_digest: str
|
||||||
|
created_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("provider key reference", self.provider_key_ref),
|
||||||
|
("wrapped key reference", self.wrapped_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("ciphertext digest", self.ciphertext_digest),
|
||||||
|
("authenticated context digest", self.authenticated_context_digest),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provenance, "Cipher provenance")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentCipherDecryptRequest:
|
||||||
|
tenant_id: str
|
||||||
|
provider_key_ref: str
|
||||||
|
wrapped_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
ciphertext: bytes = field(repr=False)
|
||||||
|
ciphertext_digest: str
|
||||||
|
authenticated_context: bytes = field(repr=False)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("provider key reference", self.provider_key_ref),
|
||||||
|
("wrapped key reference", self.wrapped_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("ciphertext digest", self.ciphertext_digest),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
if not self.authenticated_context:
|
||||||
|
raise ValueError("Authenticated context is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentCipherDecryptResult:
|
||||||
|
plaintext: bytes = field(repr=False)
|
||||||
|
provider_id: str
|
||||||
|
provider_key_ref: str
|
||||||
|
wrapped_key_ref: str
|
||||||
|
verified_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("provider key reference", self.provider_key_ref),
|
||||||
|
("wrapped key reference", self.wrapped_key_ref),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provenance, "Cipher provenance")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentKeyRewrapRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_provider_key_ref: str
|
||||||
|
source_wrapped_key_ref: str
|
||||||
|
target_provider_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
idempotency_key: str
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("source provider key reference", self.source_provider_key_ref),
|
||||||
|
("source wrapped key reference", self.source_wrapped_key_ref),
|
||||||
|
("target provider key reference", self.target_provider_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentKeyRewrapResult:
|
||||||
|
provider_id: str
|
||||||
|
source_wrapped_key_ref: str
|
||||||
|
target_provider_key_ref: str
|
||||||
|
target_wrapped_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
completed_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("source wrapped key reference", self.source_wrapped_key_ref),
|
||||||
|
("target provider key reference", self.target_provider_key_ref),
|
||||||
|
("target wrapped key reference", self.target_wrapped_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provenance, "Rewrap provenance")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentProtectionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
owner_module: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
profile_id: str
|
||||||
|
vault_id: str
|
||||||
|
ciphertext_ref: str
|
||||||
|
plaintext: bytes = field(repr=False)
|
||||||
|
policy_decision_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("owner module", self.owner_module),
|
||||||
|
("resource type", self.resource_type),
|
||||||
|
("resource id", self.resource_id),
|
||||||
|
("profile id", self.profile_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("ciphertext reference", self.ciphertext_ref),
|
||||||
|
("policy decision reference", self.policy_decision_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.metadata, "Protection metadata")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProtectedContent:
|
||||||
|
envelope: ContentProtectionEnvelope
|
||||||
|
ciphertext: bytes = field(repr=False)
|
||||||
|
replayed: bool = False
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentUnprotectionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
owner_module: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
envelope_id: str
|
||||||
|
ciphertext: bytes = field(repr=False)
|
||||||
|
actor_id: str | None = None
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("owner module", self.owner_module),
|
||||||
|
("resource type", self.resource_type),
|
||||||
|
("resource id", self.resource_id),
|
||||||
|
("envelope id", self.envelope_id),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ProtectionRegistrationRequest:
|
class ProtectionRegistrationRequest:
|
||||||
envelope: ContentProtectionEnvelope
|
envelope: ContentProtectionEnvelope
|
||||||
@@ -453,6 +671,41 @@ class EncryptionKeyMaterialProvider(Protocol):
|
|||||||
) -> KeyMaterialDescriptor | None: ...
|
) -> KeyMaterialDescriptor | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionContentCipherProvider(Protocol):
|
||||||
|
"""Cryptographic provider that never exports plaintext content keys."""
|
||||||
|
|
||||||
|
def encrypt_content(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: ContentCipherEncryptRequest,
|
||||||
|
) -> ContentCipherEncryptResult: ...
|
||||||
|
|
||||||
|
def decrypt_content(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: ContentCipherDecryptRequest,
|
||||||
|
) -> ContentCipherDecryptResult: ...
|
||||||
|
|
||||||
|
def rewrap_content_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: ContentKeyRewrapRequest,
|
||||||
|
) -> ContentKeyRewrapResult: ...
|
||||||
|
|
||||||
|
def destroy_wrapped_content_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
wrapped_key_ref: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class EncryptionKeyVault(Protocol):
|
class EncryptionKeyVault(Protocol):
|
||||||
def create_vault(
|
def create_vault(
|
||||||
@@ -553,6 +806,43 @@ class ContentProtectionRegistry(Protocol):
|
|||||||
) -> ProtectionMigrationResult: ...
|
) -> ProtectionMigrationResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionContentCipher(Protocol):
|
||||||
|
"""Owner-facing content encryption and decryption boundary."""
|
||||||
|
|
||||||
|
def protect_content(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: ContentProtectionRequest,
|
||||||
|
) -> ProtectedContent: ...
|
||||||
|
|
||||||
|
def unprotect_content(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: ContentUnprotectionRequest,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
def execute_rewrap(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
migration_id: str,
|
||||||
|
) -> ProtectionMigrationResult: ...
|
||||||
|
|
||||||
|
def prepare_reencryption(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
migration_id: str,
|
||||||
|
source_ciphertext: bytes,
|
||||||
|
target_ciphertext_ref: str,
|
||||||
|
) -> ProtectedContent: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class EncryptionRecoveryCeremony(Protocol):
|
class EncryptionRecoveryCeremony(Protocol):
|
||||||
def request_recovery(
|
def request_recovery(
|
||||||
@@ -594,6 +884,19 @@ def key_material_provider(
|
|||||||
return capability if isinstance(capability, EncryptionKeyMaterialProvider) else None
|
return capability if isinstance(capability, EncryptionKeyMaterialProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def content_cipher_provider(
|
||||||
|
registry: object | None,
|
||||||
|
provider_id: str,
|
||||||
|
) -> EncryptionContentCipherProvider | None:
|
||||||
|
capability = _capability(
|
||||||
|
registry,
|
||||||
|
f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{provider_id}",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
capability if isinstance(capability, EncryptionContentCipherProvider) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def encryption_key_vault(registry: object | None) -> EncryptionKeyVault | None:
|
def encryption_key_vault(registry: object | None) -> EncryptionKeyVault | None:
|
||||||
value = _capability(registry, CAPABILITY_ENCRYPTION_KEY_VAULT)
|
value = _capability(registry, CAPABILITY_ENCRYPTION_KEY_VAULT)
|
||||||
return value if isinstance(value, EncryptionKeyVault) else None
|
return value if isinstance(value, EncryptionKeyVault) else None
|
||||||
@@ -606,6 +909,13 @@ def content_protection_registry(
|
|||||||
return value if isinstance(value, ContentProtectionRegistry) else None
|
return value if isinstance(value, ContentProtectionRegistry) else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_content_cipher(
|
||||||
|
registry: object | None,
|
||||||
|
) -> EncryptionContentCipher | None:
|
||||||
|
value = _capability(registry, CAPABILITY_ENCRYPTION_CONTENT_CIPHER)
|
||||||
|
return value if isinstance(value, EncryptionContentCipher) else None
|
||||||
|
|
||||||
|
|
||||||
def encryption_recovery_ceremony(
|
def encryption_recovery_ceremony(
|
||||||
registry: object | None,
|
registry: object | None,
|
||||||
) -> EncryptionRecoveryCeremony | None:
|
) -> EncryptionRecoveryCeremony | None:
|
||||||
@@ -659,14 +969,26 @@ def _reject_secret_mapping(value: Mapping[str, object], label: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CAPABILITY_ENCRYPTION_CONTENT_CIPHER",
|
||||||
|
"CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX",
|
||||||
"CAPABILITY_ENCRYPTION_CONTENT_PROTECTION",
|
"CAPABILITY_ENCRYPTION_CONTENT_PROTECTION",
|
||||||
"CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT",
|
"CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT",
|
||||||
"CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX",
|
"CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX",
|
||||||
"CAPABILITY_ENCRYPTION_KEY_VAULT",
|
"CAPABILITY_ENCRYPTION_KEY_VAULT",
|
||||||
"CAPABILITY_ENCRYPTION_RECOVERY",
|
"CAPABILITY_ENCRYPTION_RECOVERY",
|
||||||
|
"ContentCipherDecryptRequest",
|
||||||
|
"ContentCipherDecryptResult",
|
||||||
|
"ContentCipherEncryptRequest",
|
||||||
|
"ContentCipherEncryptResult",
|
||||||
|
"ContentKeyRewrapRequest",
|
||||||
|
"ContentKeyRewrapResult",
|
||||||
|
"ContentProtectionRequest",
|
||||||
"ContentProtectionEnvelope",
|
"ContentProtectionEnvelope",
|
||||||
"ContentProtectionRegistry",
|
"ContentProtectionRegistry",
|
||||||
|
"ContentUnprotectionRequest",
|
||||||
"DisablePreflightReport",
|
"DisablePreflightReport",
|
||||||
|
"EncryptionContentCipher",
|
||||||
|
"EncryptionContentCipherProvider",
|
||||||
"EncryptionDisablePreflight",
|
"EncryptionDisablePreflight",
|
||||||
"EncryptionKeyMaterialProvider",
|
"EncryptionKeyMaterialProvider",
|
||||||
"EncryptionKeyVault",
|
"EncryptionKeyVault",
|
||||||
@@ -681,10 +1003,13 @@ __all__ = [
|
|||||||
"ProtectionMigrationRequest",
|
"ProtectionMigrationRequest",
|
||||||
"ProtectionMigrationResult",
|
"ProtectionMigrationResult",
|
||||||
"ProtectionRegistrationRequest",
|
"ProtectionRegistrationRequest",
|
||||||
|
"ProtectedContent",
|
||||||
"RecoveryApprovalRequest",
|
"RecoveryApprovalRequest",
|
||||||
"RecoveryRef",
|
"RecoveryRef",
|
||||||
"RecoveryRequest",
|
"RecoveryRequest",
|
||||||
|
"content_cipher_provider",
|
||||||
"content_protection_registry",
|
"content_protection_registry",
|
||||||
|
"encryption_content_cipher",
|
||||||
"encryption_disable_preflight",
|
"encryption_disable_preflight",
|
||||||
"encryption_key_vault",
|
"encryption_key_vault",
|
||||||
"encryption_recovery_ceremony",
|
"encryption_recovery_ceremony",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Protocol, runtime_checkable
|
import json
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_VOTING_BALLOTS = "voting.ballots"
|
CAPABILITY_VOTING_BALLOTS = "voting.ballots"
|
||||||
@@ -109,6 +110,9 @@ class VotingResult:
|
|||||||
result_sha256: str
|
result_sha256: str
|
||||||
evidence: tuple[Mapping[str, object], ...] = ()
|
evidence: tuple[Mapping[str, object], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_provider_evidence(self.evidence)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ExternalVotingFinalizationRequest:
|
class ExternalVotingFinalizationRequest:
|
||||||
@@ -124,6 +128,54 @@ class ExternalVotingFinalizationRequest:
|
|||||||
idempotency_key: str
|
idempotency_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalVotingPreparationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
ballot_id: str
|
||||||
|
requested_provider_ballot_ref: str | None
|
||||||
|
definition_sha256: str
|
||||||
|
electorate_sha256: str
|
||||||
|
assurance_profile: Literal["confidential", "secret", "external_certified"]
|
||||||
|
method: str
|
||||||
|
options: tuple[VotingOption, ...]
|
||||||
|
electorate: tuple[VotingElector, ...]
|
||||||
|
allow_replacement: bool
|
||||||
|
quorum_weight: int
|
||||||
|
threshold_numerator: int
|
||||||
|
threshold_denominator: int
|
||||||
|
opens_at: datetime | None
|
||||||
|
closes_at: datetime | None
|
||||||
|
requested_at: datetime
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalVotingBallotRef:
|
||||||
|
provider_id: str
|
||||||
|
provider_ballot_ref: str
|
||||||
|
state: Literal["prepared", "open", "closed", "outcome_unknown"]
|
||||||
|
definition_sha256: str
|
||||||
|
electorate_sha256: str
|
||||||
|
evidence: tuple[Mapping[str, object], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_provider_evidence(self.evidence)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalVotingCastRequest:
|
||||||
|
tenant_id: str
|
||||||
|
ballot_id: str
|
||||||
|
provider_ballot_ref: str
|
||||||
|
definition_sha256: str
|
||||||
|
electorate_sha256: str
|
||||||
|
elector_id: str
|
||||||
|
selections: tuple[str, ...]
|
||||||
|
allow_replacement: bool
|
||||||
|
requested_at: datetime
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class ExternalVotingProvider(Protocol):
|
class ExternalVotingProvider(Protocol):
|
||||||
"""Provider boundary for confidential, secret, or certified voting.
|
"""Provider boundary for confidential, secret, or certified voting.
|
||||||
@@ -141,6 +193,36 @@ class ExternalVotingProvider(Protocol):
|
|||||||
) -> VotingResult: ...
|
) -> VotingResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class InteractiveExternalVotingProvider(ExternalVotingProvider, Protocol):
|
||||||
|
"""Provider that owns external ballot preparation, casting, and tallying."""
|
||||||
|
|
||||||
|
def prepare_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingPreparationRequest,
|
||||||
|
) -> ExternalVotingBallotRef: ...
|
||||||
|
|
||||||
|
def cast_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingCastRequest,
|
||||||
|
) -> VotingReceipt: ...
|
||||||
|
|
||||||
|
def ballot_status(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
provider_ballot_ref: str,
|
||||||
|
) -> ExternalVotingBallotRef | None: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class VotingBallotProvider(Protocol):
|
class VotingBallotProvider(Protocol):
|
||||||
def create_ballot(
|
def create_ballot(
|
||||||
@@ -201,11 +283,62 @@ class VotingBallotProvider(Protocol):
|
|||||||
) -> VotingBallotRef: ...
|
) -> VotingBallotRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider_evidence(
|
||||||
|
evidence: Sequence[Mapping[str, object]],
|
||||||
|
) -> None:
|
||||||
|
if len(evidence) > 64:
|
||||||
|
raise ValueError("Voting provider evidence exceeds the item limit")
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(
|
||||||
|
[dict(item) for item in evidence],
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Voting provider evidence must be bounded JSON") from exc
|
||||||
|
if len(encoded) > 64 * 1024:
|
||||||
|
raise ValueError("Voting provider evidence exceeds the size limit")
|
||||||
|
_reject_sensitive_evidence(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_sensitive_evidence(value: object) -> None:
|
||||||
|
sensitive_fragments = (
|
||||||
|
"access_token",
|
||||||
|
"authorization",
|
||||||
|
"credential",
|
||||||
|
"cookie",
|
||||||
|
"key_material",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"plaintext",
|
||||||
|
"private_key",
|
||||||
|
"raw_vote",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"selection",
|
||||||
|
)
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, nested in value.items():
|
||||||
|
normalized = str(key).strip().lower().replace("-", "_")
|
||||||
|
if any(fragment in normalized for fragment in sensitive_fragments):
|
||||||
|
raise ValueError("Voting provider evidence contains a sensitive field")
|
||||||
|
_reject_sensitive_evidence(nested)
|
||||||
|
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||||
|
for nested in value:
|
||||||
|
_reject_sensitive_evidence(nested)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CAPABILITY_VOTING_BALLOTS",
|
"CAPABILITY_VOTING_BALLOTS",
|
||||||
"CAPABILITY_VOTING_PROVIDER_PREFIX",
|
"CAPABILITY_VOTING_PROVIDER_PREFIX",
|
||||||
|
"ExternalVotingBallotRef",
|
||||||
|
"ExternalVotingCastRequest",
|
||||||
"ExternalVotingFinalizationRequest",
|
"ExternalVotingFinalizationRequest",
|
||||||
|
"ExternalVotingPreparationRequest",
|
||||||
"ExternalVotingProvider",
|
"ExternalVotingProvider",
|
||||||
|
"InteractiveExternalVotingProvider",
|
||||||
"VOTING_ASSURANCE_CONFIDENTIAL",
|
"VOTING_ASSURANCE_CONFIDENTIAL",
|
||||||
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
|
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
|
||||||
"VOTING_ASSURANCE_RECORDED",
|
"VOTING_ASSURANCE_RECORDED",
|
||||||
|
|||||||
@@ -3412,7 +3412,10 @@ finally:
|
|||||||
"version_min": "0.1.0",
|
"version_min": "0.1.0",
|
||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["files"]["requires_interfaces"])
|
}, modules["files"]["requires_interfaces"])
|
||||||
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
self.assertEqual(
|
||||||
|
["campaigns", "encryption"],
|
||||||
|
modules["files"]["optional_dependencies"],
|
||||||
|
)
|
||||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||||
self.assertIn({
|
self.assertIn({
|
||||||
"name": "campaigns.access",
|
"name": "campaigns.access",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.voting import VotingResult
|
||||||
|
|
||||||
|
|
||||||
|
def result_with_evidence(*evidence):
|
||||||
|
return VotingResult(
|
||||||
|
ballot_id="ballot-1",
|
||||||
|
revision=2,
|
||||||
|
counts={"yes": 1},
|
||||||
|
weighted_counts={"yes": 1},
|
||||||
|
cast_count=1,
|
||||||
|
cast_weight=1,
|
||||||
|
eligible_count=1,
|
||||||
|
eligible_weight=1,
|
||||||
|
quorum_met=True,
|
||||||
|
threshold_met=True,
|
||||||
|
winning_options=("yes",),
|
||||||
|
result_sha256="a" * 64,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VotingContractTests(unittest.TestCase):
|
||||||
|
def test_accepts_sanitized_provider_evidence(self) -> None:
|
||||||
|
value = result_with_evidence(
|
||||||
|
{
|
||||||
|
"kind": "reference_provider_result",
|
||||||
|
"provider_id": "local_confidential",
|
||||||
|
"certified": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("local_confidential", value.evidence[0]["provider_id"])
|
||||||
|
|
||||||
|
def test_rejects_secret_or_raw_selection_evidence(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "sensitive field"):
|
||||||
|
result_with_evidence({"access_token": "not-for-projection"})
|
||||||
|
with self.assertRaisesRegex(ValueError, "sensitive field"):
|
||||||
|
result_with_evidence({"nested": {"selections": ["yes"]}})
|
||||||
|
|
||||||
|
def test_rejects_non_json_or_oversized_evidence(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "bounded JSON"):
|
||||||
|
result_with_evidence({"value": b"binary"})
|
||||||
|
with self.assertRaisesRegex(ValueError, "size limit"):
|
||||||
|
result_with_evidence({"value": "x" * (64 * 1024)})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user