feat: finalize encryption and voting provider contracts

This commit is contained in:
2026-08-02 03:40:44 +02:00
parent 2b4eb0151f
commit 972c681650
5 changed files with 522 additions and 9 deletions
+2 -2
View File
@@ -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.
+330 -5
View File
@@ -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",
+134 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+52
View File
@@ -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()