From 42f35f8d00c927b4b312dc2b8c498bb20d0ad757 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 03:40:50 +0200 Subject: [PATCH] feat: add governed local content encryption provider --- README.md | 25 +- docs/ENCRYPTION_BOUNDARY.md | 56 +- pyproject.toml | 2 +- src/govoplan_encryption/backend/db/models.py | 140 +++ .../backend/local_provider.py | 868 ++++++++++++++++++ src/govoplan_encryption/backend/manifest.py | 56 +- ...5b7c9d1f3a4_v0114_local_crypto_provider.py | 154 ++++ src/govoplan_encryption/backend/service.py | 560 ++++++++++- tests/test_local_provider.py | 471 ++++++++++ tests/test_manifest.py | 7 + tests/test_migrations.py | 5 +- 11 files changed, 2318 insertions(+), 26 deletions(-) create mode 100644 src/govoplan_encryption/backend/local_provider.py create mode 100644 src/govoplan_encryption/backend/migrations/versions/e5b7c9d1f3a4_v0114_local_crypto_provider.py create mode 100644 tests/test_local_provider.py diff --git a/README.md b/README.md index 938f024..c4aae05 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,25 @@ recovery ceremonies, and disable/uninstall assurance. state transitions; - recent high-assurance, distinct-custodian quorum recovery authorization; - typed APIs, audit-safe events, Alembic migration, and uninstall blocking; -- Files and Postbox boundary fixtures. +- a bundled `local_aesgcm` server-envelope provider using AES-256-GCM and + SQL-persisted wrapped vault/content keys; +- owner-facing content protect/open/rewrap and two-phase re-encryption + operations that never export key material; +- Files and Postbox encrypted-content adapters and fail-closed fixtures. -## Deliberately Not Bundled +## Provider Boundary -The module contains no cipher implementation, raw key store, development master -key, KMS/HSM driver, client E2EE protocol, or provider credentials. A deployment -must install a separately reviewed provider capability. Until a concrete profile -passes conformance, backup/restore, key-loss, interoperability, and security -review, GovOPlaN must not claim that content is encrypted or end-to-end -encrypted. +The bundled provider derives a deployment wrapping key from `MASTER_KEY_B64` and +keeps vault and per-content keys wrapped in shared SQL state. It provides real +authenticated encryption at rest, but the application server can decrypt and +therefore this is not E2EE, an HSM/KMS assurance claim, or independent +certification. Production backup and restore must preserve the database, +ciphertext storage, and exact deployment key as separate governed assets. + +No client E2EE protocol, tenant KMS/HSM driver, provider credentials, or formal +certification profile is bundled. Those remain provider-specific work and must +pass conformance, backup/restore, key-loss, interoperability, and security review +before a stronger product claim is made. Feature modules continue to own content, authorization, retention, and resource ownership. Access approval, resource ownership, Identity Trust, and key custody diff --git a/docs/ENCRYPTION_BOUNDARY.md b/docs/ENCRYPTION_BOUNDARY.md index 3ca2225..2d25010 100644 --- a/docs/ENCRYPTION_BOUNDARY.md +++ b/docs/ENCRYPTION_BOUNDARY.md @@ -5,11 +5,16 @@ `govoplan-encryption` is the optional governance and lifecycle boundary for cryptographic protection. It persists opaque provider references, versioned content-protection envelopes, idempotent operation state, recovery decisions, -and disable-preflight evidence. It does not ship a cipher, a raw key store, a -KMS/HSM driver, or a client E2EE protocol. +and disable-preflight evidence. It ships a bounded `local_aesgcm` server-envelope +provider. The provider uses AES-256-GCM, wraps generated vault keys under a key +derived from deployment `MASTER_KEY_B64`, wraps one generated content key per +envelope, and persists only wrapped keys, nonces, keyed commitments, ciphertext +digests, and opaque references +in shared SQL state. It does not ship a KMS/HSM driver or client E2EE protocol. -Enabling the module is therefore not an encryption or E2EE claim. A deployment -may make such a claim only for a named protection profile whose concrete +Selecting the bundled profile is an authenticated encryption-at-rest claim for +the covered payload, not an E2EE, endpoint-security, HSM/KMS, or legal +certification claim. Stronger claims require a named protection profile whose provider, clients, algorithms, backup procedure, and failure tests have passed the profile's conformance and security review. @@ -72,6 +77,31 @@ encryption hides this metadata. ## Deployment Profiles +### Bundled local AES-GCM reference provider + +`local_aesgcm` is a concrete server-side envelope provider intended for bounded +deployments and integration verification. A per-deployment wrapping key is +derived with HKDF-SHA-256 from `MASTER_KEY_B64`; the master key itself is never +stored in the database. Vault keys and content keys are generated with the +operating-system CSPRNG and wrapped with AES-256-GCM. Every content ciphertext +authenticates a canonical tenant, owner, resource, profile, provider, vault, +key-version, and algorithm context. + +The provider stores no unkeyed plaintext digest. Its internal plaintext +commitment is keyed by deployment custody and bound to the authenticated +resource context, preventing an offline database reader from enumerating +low-entropy values such as ballot choices. + +The provider supports idempotent provision, rotation, revocation, destruction +scheduling, content encryption/decryption, and same-provider key rewrap. A +revoked vault key cannot protect new content but can open existing content until +destruction. Due destruction clears wrapped vault material and causes reads to +fail closed. Payload re-encryption is an explicit two-phase owner-coordinated +workflow: Encryption decrypts and prepares an idempotent target ciphertext and +exact envelope, while the owner must durably replace its payload before it +records the migration as successful. This also permits cross-provider or +algorithm migration without giving Encryption ownership of the payload store. + ### Server-side envelope encryption The application or a deployment KMS/HSM can unwrap data keys after normal @@ -143,9 +173,15 @@ operation as `prepared`, `succeeded`, or `outcome_unknown`. If a provider call raises or a process dies around the external side effect, absence of an error is never treated as success. Reconciliation repeats the exact idempotent provider request and records only bounded error class codes. +An `outcome_unknown` content migration may subsequently be resolved to success +or rejection; it is not an immutable terminal state. Content migrations are explicit records. Rewrap and re-encryption succeed only when an exact target envelope references the authorized source and migration. +Re-encryption preparation leaves the source in `migrating` and does not register +the active target envelope. Repeating it with the same migration and target +reference returns identical authenticated ciphertext. Only the owner's +evidence-backed outcome call activates the target and retires the source. Decrypt, export, and destruction require evidence references. Until an outcome is known, the source remains `migration_pending`; no implicit plaintext fallback is allowed. @@ -206,7 +242,11 @@ They resolve Encryption through optional capabilities and must fail closed when an encrypted record is encountered without the capability. Unencrypted module combinations continue to operate without Encryption. -Focused fixtures currently validate Files- and Postbox-owned envelope records. -Concrete feature adapters and production provider conformance remain tracked -work; direct imports of Encryption database models from feature modules are not -allowed. +Focused fixtures validate Files and Postbox owner adapters: plaintext operation +continues without Encryption, configured encrypted writes store ciphertext and +an envelope link, encrypted reads fail closed when the capability is absent, +and feature modules do not import Encryption models or providers. Files keeps +plaintext semantic checksums separate from stored-ciphertext integrity and uses +the protection profile in its deduplication identity. Postbox encrypts message +bodies while subjects, routing, participant, and attachment-reference metadata +remain observable. diff --git a/pyproject.toml b/pyproject.toml index 484b7a5..46f7cee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.12" license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] -dependencies = ["govoplan-core>=0.1.14"] +dependencies = ["cryptography>=44", "govoplan-core>=0.1.14"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_encryption/backend/db/models.py b/src/govoplan_encryption/backend/db/models.py index 57e3b0f..acec89f 100644 --- a/src/govoplan_encryption/backend/db/models.py +++ b/src/govoplan_encryption/backend/db/models.py @@ -9,6 +9,7 @@ from sqlalchemy import ( Index, Integer, JSON, + LargeBinary, String, Text, UniqueConstraint, @@ -150,6 +151,142 @@ class EncryptionKeyOperation(Base, TimestampMixin): ) +class EncryptionLocalKeyMaterial(Base, TimestampMixin): + """Provider-owned wrapped data-encryption key material. + + The configured deployment master key never enters this table. The raw data + key exists only inside the provider while a cryptographic operation runs. + """ + + __tablename__ = "encryption_local_key_material" + __table_args__ = ( + UniqueConstraint( + "provider_key_ref", + name="uq_encryption_local_provider_key_ref", + ), + UniqueConstraint( + "tenant_id", + "vault_id", + "key_version", + name="uq_encryption_local_vault_key_version", + ), + UniqueConstraint( + "tenant_id", + "provision_idempotency_key", + name="uq_encryption_local_provision_idempotency", + ), + Index( + "ix_encryption_local_key_state", + "tenant_id", + "state", + "updated_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) + vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + key_version: Mapped[int] = mapped_column(Integer, nullable=False) + provider_key_ref: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + algorithm_suite: Mapped[str] = mapped_column(String(120), nullable=False) + purpose: Mapped[str] = mapped_column(String(255), nullable=False) + state: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + wrapped_key: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + wrap_context_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + provision_idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + provision_request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + destruction_scheduled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + destroyed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + +class EncryptionLocalWrappedContentKey(Base, TimestampMixin): + __tablename__ = "encryption_local_wrapped_content_keys" + __table_args__ = ( + UniqueConstraint( + "wrapped_key_ref", + name="uq_encryption_local_wrapped_key_ref", + ), + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_encryption_local_content_idempotency", + ), + Index( + "ix_encryption_local_content_state", + "tenant_id", + "state", + "updated_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) + wrapped_key_ref: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + provider_key_ref: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + algorithm_suite: Mapped[str] = mapped_column(String(120), nullable=False) + state: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + wrapped_content_key: Mapped[bytes | None] = mapped_column( + LargeBinary, nullable=True + ) + wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + content_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + authenticated_context_sha256: Mapped[str] = mapped_column( + String(64), nullable=False + ) + plaintext_commitment: Mapped[str] = mapped_column(String(64), nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + source_wrapped_key_ref: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + destroyed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + +class EncryptionLocalProviderOperation(Base, TimestampMixin): + __tablename__ = "encryption_local_provider_operations" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_encryption_local_provider_operation_idem", + ), + Index( + "ix_encryption_local_provider_operation_state", + "tenant_id", + "state", + "updated_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) + operation: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + target_ref: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + outcome_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + state: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + class ContentProtectionRecord(Base, TimestampMixin): __tablename__ = "encryption_content_protections" __table_args__ = ( @@ -305,6 +442,9 @@ __all__ = [ "ContentProtectionRecord", "EncryptionKeyOperation", "EncryptionKeyVersion", + "EncryptionLocalKeyMaterial", + "EncryptionLocalProviderOperation", + "EncryptionLocalWrappedContentKey", "EncryptionVault", "ProtectionMigration", "RecoveryApproval", diff --git a/src/govoplan_encryption/backend/local_provider.py b/src/govoplan_encryption/backend/local_provider.py new file mode 100644 index 0000000..ccb25ad --- /dev/null +++ b/src/govoplan_encryption/backend/local_provider.py @@ -0,0 +1,868 @@ +from __future__ import annotations + +import base64 +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +import hashlib +import hmac +import json +import os +from typing import Any +import uuid + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from sqlalchemy import select +from sqlalchemy.orm import Session + +from govoplan_core.core.encryption import ( + ContentCipherDecryptRequest, + ContentCipherDecryptResult, + ContentCipherEncryptRequest, + ContentCipherEncryptResult, + ContentKeyRewrapRequest, + ContentKeyRewrapResult, + KeyMaterialDescriptor, + KeyMaterialProvisionRequest, +) +from govoplan_encryption.backend.db.models import ( + EncryptionLocalKeyMaterial, + EncryptionLocalProviderOperation, + EncryptionLocalWrappedContentKey, +) + + +LOCAL_PROVIDER_ID = "local_aesgcm" +LOCAL_PROVIDER_VERSION = "1" +LOCAL_ALGORITHM_SUITE = "AES-256-GCM" +_NONCE_BYTES = 12 +_KEY_BYTES = 32 + + +class LocalProviderError(ValueError): + pass + + +class LocalProviderConfigurationError(LocalProviderError): + pass + + +class LocalAesGcmProvider: + """Reference server-side provider backed by SQL and ``MASTER_KEY_B64``. + + Vault keys and per-content keys are stored only as AES-GCM wrapped bytes. + Plain key material exists transiently inside this provider and is never + returned through a GovOPlaN capability. + """ + + provider_id = LOCAL_PROVIDER_ID + provider_version = LOCAL_PROVIDER_VERSION + + def __init__( + self, + master_key: str | bytes | None, + *, + random_bytes: Callable[[int], bytes] = os.urandom, + reference_factory: Callable[[], str] | None = None, + fault_injector: Callable[[str], None] | None = None, + ) -> None: + self._root_key = _normalize_master_key(master_key) + self._wrapping_key = HKDF( + algorithm=hashes.SHA256(), + length=_KEY_BYTES, + salt=b"GovOPlaN/local-aesgcm-provider/v1", + info=b"deployment-root-key-wrap", + ).derive(self._root_key) + self._random_bytes = random_bytes + self._reference_factory = reference_factory or (lambda: str(uuid.uuid4())) + self._fault_injector = fault_injector + + def provision_key( + self, + session: object, + principal: object, + *, + request: KeyMaterialProvisionRequest, + ) -> KeyMaterialDescriptor: + db = _session(session) + _require_tenant(principal, request.tenant_id) + _require_algorithm(request.algorithm_suite) + if request.import_reference: + raise LocalProviderError( + "The local provider accepts no raw or cross-provider key imports." + ) + payload = { + "tenant_id": request.tenant_id, + "vault_id": request.vault_id, + "key_version": request.key_version, + "algorithm_suite": request.algorithm_suite, + "purpose": request.purpose, + "provider_policy": dict(request.provider_policy), + } + request_digest = _digest(payload) + replay = db.scalar( + select(EncryptionLocalKeyMaterial).where( + EncryptionLocalKeyMaterial.tenant_id == request.tenant_id, + EncryptionLocalKeyMaterial.provision_idempotency_key + == request.idempotency_key, + ) + ) + if replay is not None: + if replay.provision_request_sha256 != request_digest: + raise LocalProviderError( + "The provider idempotency key was reused for another request." + ) + self._apply_due_destruction(replay) + return self._descriptor(replay) + + provider_key_ref = f"local-aesgcm:key:{self._reference_factory()}" + raw_key = self._random_bytes(_KEY_BYTES) + wrap_nonce = self._random_bytes(_NONCE_BYTES) + context = _vault_wrap_context( + tenant_id=request.tenant_id, + vault_id=request.vault_id, + key_version=request.key_version, + provider_key_ref=provider_key_ref, + algorithm_suite=request.algorithm_suite, + purpose=request.purpose, + ) + wrapped = AESGCM(self._wrapping_key).encrypt(wrap_nonce, raw_key, context) + item = EncryptionLocalKeyMaterial( + tenant_id=request.tenant_id, + vault_id=request.vault_id, + key_version=request.key_version, + provider_key_ref=provider_key_ref, + algorithm_suite=request.algorithm_suite, + purpose=request.purpose, + state="active", + wrapped_key=wrapped, + wrap_nonce=wrap_nonce, + wrap_context_sha256=_sha256(context), + provision_idempotency_key=request.idempotency_key, + provision_request_sha256=request_digest, + ) + db.add(item) + self._fault("provision.before_flush") + db.flush() + return self._descriptor(item) + + def revoke_key( + self, + session: object, + principal: object, + *, + provider_key_ref: str, + reason: str, + idempotency_key: str, + ) -> KeyMaterialDescriptor: + db = _session(session) + item = self._key(db, provider_key_ref) + _require_tenant(principal, item.tenant_id) + payload = { + "operation": "revoke", + "provider_key_ref": provider_key_ref, + "reason": str(reason or "").strip(), + } + replay = self._operation( + db, + tenant_id=item.tenant_id, + operation="revoke", + target_ref=provider_key_ref, + idempotency_key=idempotency_key, + payload=payload, + ) + self._apply_due_destruction(item) + if replay.state != "succeeded": + if item.state == "destroyed": + raise LocalProviderError("Destroyed key material cannot be revoked.") + item.state = "revoked" + item.revoked_at = _now() + replay.state = "succeeded" + replay.outcome_ref = provider_key_ref + replay.completed_at = _now() + db.flush() + return self._descriptor(item) + + def schedule_key_destruction( + self, + session: object, + principal: object, + *, + provider_key_ref: str, + effective_at: datetime, + idempotency_key: str, + ) -> KeyMaterialDescriptor: + db = _session(session) + item = self._key(db, provider_key_ref) + _require_tenant(principal, item.tenant_id) + effective = _aware(effective_at) + payload = { + "operation": "schedule_destruction", + "provider_key_ref": provider_key_ref, + "effective_at": effective.isoformat(), + } + replay = self._operation( + db, + tenant_id=item.tenant_id, + operation="schedule_destruction", + target_ref=provider_key_ref, + idempotency_key=idempotency_key, + payload=payload, + ) + self._apply_due_destruction(item) + if replay.state != "succeeded": + if item.state != "destroyed": + item.state = "destruction_scheduled" + item.destruction_scheduled_at = effective + self._apply_due_destruction(item) + replay.state = "succeeded" + replay.outcome_ref = provider_key_ref + replay.completed_at = _now() + db.flush() + return self._descriptor(item) + + def key_status( + self, + session: object, + *, + provider_key_ref: str, + ) -> KeyMaterialDescriptor | None: + db = _session(session) + item = db.scalar( + select(EncryptionLocalKeyMaterial).where( + EncryptionLocalKeyMaterial.provider_key_ref == provider_key_ref + ) + ) + if item is None: + return None + self._apply_due_destruction(item) + db.flush() + return self._descriptor(item) + + def encrypt_content( + self, + session: object, + *, + request: ContentCipherEncryptRequest, + ) -> ContentCipherEncryptResult: + db = _session(session) + _require_algorithm(request.algorithm_suite) + key = self._key(db, request.provider_key_ref) + self._validate_key_request( + key, + tenant_id=request.tenant_id, + vault_id=request.vault_id, + key_version=request.key_version, + algorithm_suite=request.algorithm_suite, + new_content=True, + ) + context_digest = _sha256(request.authenticated_context) + plaintext_commitment = self._plaintext_commitment( + request.plaintext, + request.authenticated_context, + ) + request_digest = _digest( + { + "tenant_id": request.tenant_id, + "vault_id": request.vault_id, + "key_version": request.key_version, + "provider_key_ref": request.provider_key_ref, + "algorithm_suite": request.algorithm_suite, + "plaintext_commitment": plaintext_commitment, + "authenticated_context_sha256": context_digest, + } + ) + item = db.scalar( + select(EncryptionLocalWrappedContentKey).where( + EncryptionLocalWrappedContentKey.tenant_id == request.tenant_id, + EncryptionLocalWrappedContentKey.idempotency_key + == request.idempotency_key, + ) + ) + if item is not None: + if item.request_sha256 != request_digest: + raise LocalProviderError( + "The content idempotency key was reused for another request." + ) + content_key = self._unwrap_content_key(db, item) + else: + wrapped_key_ref = f"local-aesgcm:content-key:{self._reference_factory()}" + content_key = self._random_bytes(_KEY_BYTES) + content_nonce = self._random_bytes(_NONCE_BYTES) + wrap_nonce = self._random_bytes(_NONCE_BYTES) + key_material = self._unwrap_vault_key(key) + wrap_context = _content_wrap_context( + tenant_id=request.tenant_id, + wrapped_key_ref=wrapped_key_ref, + provider_key_ref=request.provider_key_ref, + algorithm_suite=request.algorithm_suite, + authenticated_context_sha256=context_digest, + plaintext_commitment=plaintext_commitment, + ) + wrapped_content_key = AESGCM(key_material).encrypt( + wrap_nonce, + content_key, + wrap_context, + ) + item = EncryptionLocalWrappedContentKey( + tenant_id=request.tenant_id, + wrapped_key_ref=wrapped_key_ref, + provider_key_ref=request.provider_key_ref, + algorithm_suite=request.algorithm_suite, + state="active", + wrapped_content_key=wrapped_content_key, + wrap_nonce=wrap_nonce, + content_nonce=content_nonce, + authenticated_context_sha256=context_digest, + plaintext_commitment=plaintext_commitment, + idempotency_key=request.idempotency_key, + request_sha256=request_digest, + ) + db.add(item) + self._fault("encrypt.after_wrap") + db.flush() + if item.content_nonce is None: + raise LocalProviderError("Wrapped content key is unavailable.") + ciphertext = AESGCM(content_key).encrypt( + item.content_nonce, + request.plaintext, + request.authenticated_context, + ) + return ContentCipherEncryptResult( + provider_id=self.provider_id, + provider_key_ref=request.provider_key_ref, + wrapped_key_ref=item.wrapped_key_ref, + algorithm_suite=request.algorithm_suite, + ciphertext=ciphertext, + ciphertext_digest=f"sha256:{_sha256(ciphertext)}", + authenticated_context_digest=f"sha256:{context_digest}", + created_at=_aware(item.created_at), + provenance={ + "provider_version": self.provider_version, + "cipher_suite": request.algorithm_suite, + "server_readable": True, + }, + ) + + def decrypt_content( + self, + session: object, + *, + request: ContentCipherDecryptRequest, + ) -> ContentCipherDecryptResult: + db = _session(session) + _require_algorithm(request.algorithm_suite) + actual_digest = f"sha256:{_sha256(request.ciphertext)}" + if not hmac.compare_digest(actual_digest, request.ciphertext_digest): + raise LocalProviderError("Ciphertext digest verification failed.") + item = self._wrapped_key( + db, + tenant_id=request.tenant_id, + wrapped_key_ref=request.wrapped_key_ref, + ) + if item.provider_key_ref != request.provider_key_ref: + raise LocalProviderError( + "Wrapped content key belongs to another vault key." + ) + if item.algorithm_suite != request.algorithm_suite: + raise LocalProviderError( + "Wrapped content key uses another algorithm suite." + ) + context_digest = _sha256(request.authenticated_context) + if not hmac.compare_digest( + context_digest, + item.authenticated_context_sha256, + ): + raise LocalProviderError("Authenticated content context does not match.") + if item.state != "active" or item.content_nonce is None: + raise LocalProviderError("Wrapped content key is unavailable.") + content_key = self._unwrap_content_key(db, item) + try: + plaintext = AESGCM(content_key).decrypt( + item.content_nonce, + request.ciphertext, + request.authenticated_context, + ) + except InvalidTag as exc: + raise LocalProviderError("Ciphertext authentication failed.") from exc + if not hmac.compare_digest( + self._plaintext_commitment(plaintext, request.authenticated_context), + item.plaintext_commitment, + ): + raise LocalProviderError("Decrypted content digest verification failed.") + return ContentCipherDecryptResult( + plaintext=plaintext, + provider_id=self.provider_id, + provider_key_ref=request.provider_key_ref, + wrapped_key_ref=request.wrapped_key_ref, + verified_at=_now(), + provenance={ + "provider_version": self.provider_version, + "cipher_suite": request.algorithm_suite, + "integrity_verified": True, + "server_readable": True, + }, + ) + + def rewrap_content_key( + self, + session: object, + *, + request: ContentKeyRewrapRequest, + ) -> ContentKeyRewrapResult: + db = _session(session) + _require_algorithm(request.algorithm_suite) + source = self._wrapped_key( + db, + tenant_id=request.tenant_id, + wrapped_key_ref=request.source_wrapped_key_ref, + ) + if source.provider_key_ref != request.source_provider_key_ref: + raise LocalProviderError("Source wrapped key belongs to another vault key.") + target_key = self._key(db, request.target_provider_key_ref) + self._validate_key_request( + target_key, + tenant_id=request.tenant_id, + algorithm_suite=request.algorithm_suite, + new_content=True, + ) + payload = { + "tenant_id": request.tenant_id, + "source_provider_key_ref": request.source_provider_key_ref, + "source_wrapped_key_ref": request.source_wrapped_key_ref, + "target_provider_key_ref": request.target_provider_key_ref, + "algorithm_suite": request.algorithm_suite, + } + request_digest = _digest(payload) + target = db.scalar( + select(EncryptionLocalWrappedContentKey).where( + EncryptionLocalWrappedContentKey.tenant_id == request.tenant_id, + EncryptionLocalWrappedContentKey.idempotency_key + == request.idempotency_key, + ) + ) + if target is not None: + if target.request_sha256 != request_digest: + raise LocalProviderError( + "The rewrap idempotency key was reused for another request." + ) + else: + content_key = self._unwrap_content_key(db, source) + wrapped_key_ref = f"local-aesgcm:content-key:{self._reference_factory()}" + wrap_nonce = self._random_bytes(_NONCE_BYTES) + target_material = self._unwrap_vault_key(target_key) + wrap_context = _content_wrap_context( + tenant_id=request.tenant_id, + wrapped_key_ref=wrapped_key_ref, + provider_key_ref=request.target_provider_key_ref, + algorithm_suite=request.algorithm_suite, + authenticated_context_sha256=(source.authenticated_context_sha256), + plaintext_commitment=source.plaintext_commitment, + ) + target = EncryptionLocalWrappedContentKey( + tenant_id=request.tenant_id, + wrapped_key_ref=wrapped_key_ref, + provider_key_ref=request.target_provider_key_ref, + algorithm_suite=request.algorithm_suite, + state="active", + wrapped_content_key=AESGCM(target_material).encrypt( + wrap_nonce, + content_key, + wrap_context, + ), + wrap_nonce=wrap_nonce, + content_nonce=source.content_nonce, + authenticated_context_sha256=(source.authenticated_context_sha256), + plaintext_commitment=source.plaintext_commitment, + idempotency_key=request.idempotency_key, + request_sha256=request_digest, + source_wrapped_key_ref=source.wrapped_key_ref, + ) + db.add(target) + self._fault("rewrap.before_flush") + db.flush() + return ContentKeyRewrapResult( + provider_id=self.provider_id, + source_wrapped_key_ref=source.wrapped_key_ref, + target_provider_key_ref=request.target_provider_key_ref, + target_wrapped_key_ref=target.wrapped_key_ref, + algorithm_suite=request.algorithm_suite, + completed_at=_now(), + provenance={ + "provider_version": self.provider_version, + "ciphertext_unchanged": True, + "server_readable": True, + }, + ) + + def destroy_wrapped_content_key( + self, + session: object, + *, + tenant_id: str, + wrapped_key_ref: str, + idempotency_key: str, + ) -> None: + db = _session(session) + item = self._wrapped_key( + db, + tenant_id=tenant_id, + wrapped_key_ref=wrapped_key_ref, + ) + operation = self._operation( + db, + tenant_id=tenant_id, + operation="destroy_wrapped_content_key", + target_ref=wrapped_key_ref, + idempotency_key=idempotency_key, + payload={ + "operation": "destroy_wrapped_content_key", + "wrapped_key_ref": wrapped_key_ref, + }, + ) + if operation.state == "succeeded": + return + item.state = "destroyed" + item.wrapped_content_key = None + item.wrap_nonce = None + item.content_nonce = None + item.destroyed_at = _now() + operation.state = "succeeded" + operation.outcome_ref = wrapped_key_ref + operation.completed_at = _now() + db.flush() + + def _unwrap_vault_key(self, item: EncryptionLocalKeyMaterial) -> bytes: + self._apply_due_destruction(item) + if ( + item.state == "destroyed" + or item.wrapped_key is None + or item.wrap_nonce is None + ): + raise LocalProviderError("Vault key material is unavailable.") + context = _vault_wrap_context( + tenant_id=item.tenant_id, + vault_id=item.vault_id, + key_version=item.key_version, + provider_key_ref=item.provider_key_ref, + algorithm_suite=item.algorithm_suite, + purpose=item.purpose, + ) + if not hmac.compare_digest(_sha256(context), item.wrap_context_sha256): + raise LocalProviderError("Vault key context verification failed.") + try: + return AESGCM(self._wrapping_key).decrypt( + item.wrap_nonce, + item.wrapped_key, + context, + ) + except InvalidTag as exc: + raise LocalProviderError( + "Vault key cannot be recovered with the configured deployment key." + ) from exc + + def _unwrap_content_key( + self, + db: Session, + item: EncryptionLocalWrappedContentKey, + ) -> bytes: + if ( + item.state != "active" + or item.wrapped_content_key is None + or item.wrap_nonce is None + ): + raise LocalProviderError("Wrapped content key is unavailable.") + key = self._key(db, item.provider_key_ref) + key_material = self._unwrap_vault_key(key) + context = _content_wrap_context( + tenant_id=item.tenant_id, + wrapped_key_ref=item.wrapped_key_ref, + provider_key_ref=item.provider_key_ref, + algorithm_suite=item.algorithm_suite, + authenticated_context_sha256=item.authenticated_context_sha256, + plaintext_commitment=item.plaintext_commitment, + ) + try: + return AESGCM(key_material).decrypt( + item.wrap_nonce, + item.wrapped_content_key, + context, + ) + except InvalidTag as exc: + raise LocalProviderError( + "Wrapped content key authentication failed." + ) from exc + + def _validate_key_request( + self, + item: EncryptionLocalKeyMaterial, + *, + tenant_id: str, + algorithm_suite: str, + vault_id: str | None = None, + key_version: int | None = None, + new_content: bool, + ) -> None: + self._apply_due_destruction(item) + if item.tenant_id != tenant_id: + raise LocalProviderError("Cross-tenant cryptographic access is blocked.") + if vault_id is not None and item.vault_id != vault_id: + raise LocalProviderError("Provider key belongs to another vault.") + if key_version is not None and item.key_version != key_version: + raise LocalProviderError("Provider key version does not match.") + if item.algorithm_suite != algorithm_suite: + raise LocalProviderError("Provider key uses another algorithm suite.") + if new_content and item.state != "active": + raise LocalProviderError( + "Only an active vault key may protect new content." + ) + if item.state == "destroyed": + raise LocalProviderError("Vault key material is unavailable.") + + @staticmethod + def _key(db: Session, provider_key_ref: str) -> EncryptionLocalKeyMaterial: + item = db.scalar( + select(EncryptionLocalKeyMaterial).where( + EncryptionLocalKeyMaterial.provider_key_ref == provider_key_ref + ) + ) + if item is None: + raise LocalProviderError("Provider key was not found.") + return item + + @staticmethod + def _wrapped_key( + db: Session, + *, + tenant_id: str, + wrapped_key_ref: str, + ) -> EncryptionLocalWrappedContentKey: + item = db.scalar( + select(EncryptionLocalWrappedContentKey).where( + EncryptionLocalWrappedContentKey.tenant_id == tenant_id, + EncryptionLocalWrappedContentKey.wrapped_key_ref == wrapped_key_ref, + ) + ) + if item is None: + raise LocalProviderError("Wrapped content key was not found.") + return item + + @staticmethod + def _operation( + db: Session, + *, + tenant_id: str, + operation: str, + target_ref: str, + idempotency_key: str, + payload: Mapping[str, object], + ) -> EncryptionLocalProviderOperation: + request_digest = _digest(payload) + item = db.scalar( + select(EncryptionLocalProviderOperation).where( + EncryptionLocalProviderOperation.tenant_id == tenant_id, + EncryptionLocalProviderOperation.idempotency_key == idempotency_key, + ) + ) + if item is not None: + if ( + item.operation != operation + or item.target_ref != target_ref + or item.request_sha256 != request_digest + ): + raise LocalProviderError( + "The provider operation idempotency key was reused." + ) + return item + item = EncryptionLocalProviderOperation( + tenant_id=tenant_id, + operation=operation, + target_ref=target_ref, + idempotency_key=idempotency_key, + request_sha256=request_digest, + state="prepared", + ) + db.add(item) + db.flush() + return item + + @staticmethod + def _apply_due_destruction(item: EncryptionLocalKeyMaterial) -> None: + if ( + item.state == "destruction_scheduled" + and item.destruction_scheduled_at is not None + and _aware(item.destruction_scheduled_at) <= _now() + ): + item.state = "destroyed" + item.wrapped_key = None + item.wrap_nonce = None + item.destroyed_at = _now() + + def _descriptor(self, item: EncryptionLocalKeyMaterial) -> KeyMaterialDescriptor: + return KeyMaterialDescriptor( + provider_id=self.provider_id, + provider_key_ref=item.provider_key_ref, + algorithm_suite=item.algorithm_suite, + state=item.state, # type: ignore[arg-type] + created_at=_aware(item.created_at), + imported=False, + exportable=False, + provider_version=self.provider_version, + provenance={ + "provider_version": self.provider_version, + "storage": "sql_wrapped", + "server_readable": True, + }, + ) + + def _fault(self, point: str) -> None: + if self._fault_injector is not None: + self._fault_injector(point) + + def _plaintext_commitment( + self, + plaintext: bytes, + authenticated_context: bytes, + ) -> str: + return hmac.new( + self._wrapping_key, + b"GovOPlaN/local-plaintext-commitment/v1\0" + + hashlib.sha256(authenticated_context).digest() + + plaintext, + hashlib.sha256, + ).hexdigest() + + +def _normalize_master_key(value: str | bytes | None) -> bytes: + if value is None or value == b"" or value == "": + raise LocalProviderConfigurationError( + "MASTER_KEY_B64 is required for the local encryption provider." + ) + if isinstance(value, bytes): + if len(value) == _KEY_BYTES: + return bytes(value) + candidate = value.strip() + else: + try: + candidate = value.strip().encode("ascii") + except UnicodeEncodeError as exc: + raise LocalProviderConfigurationError( + "MASTER_KEY_B64 is not valid base64." + ) from exc + try: + decoded = base64.b64decode(candidate, altchars=b"-_", validate=True) + except Exception as exc: + raise LocalProviderConfigurationError( + "MASTER_KEY_B64 is not valid base64." + ) from exc + if len(decoded) != _KEY_BYTES: + raise LocalProviderConfigurationError( + "MASTER_KEY_B64 must decode to exactly 32 bytes." + ) + return decoded + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("The local encryption provider requires a SQLAlchemy Session.") + return value + + +def _require_tenant(principal: object, tenant_id: str) -> None: + observed = ( + principal.get("tenant_id") + if isinstance(principal, Mapping) + else getattr(principal, "tenant_id", None) + ) + if str(observed or "") != tenant_id: + raise LocalProviderError("Cross-tenant cryptographic access is blocked.") + + +def _require_algorithm(value: str) -> None: + if value != LOCAL_ALGORITHM_SUITE: + raise LocalProviderError( + f"The local provider supports only {LOCAL_ALGORITHM_SUITE}." + ) + + +def _vault_wrap_context( + *, + tenant_id: str, + vault_id: str, + key_version: int, + provider_key_ref: str, + algorithm_suite: str, + purpose: str, +) -> bytes: + return _canonical_bytes( + { + "context": "govoplan.local-vault-key-wrap.v1", + "tenant_id": tenant_id, + "vault_id": vault_id, + "key_version": key_version, + "provider_key_ref": provider_key_ref, + "algorithm_suite": algorithm_suite, + "purpose": purpose, + } + ) + + +def _content_wrap_context( + *, + tenant_id: str, + wrapped_key_ref: str, + provider_key_ref: str, + algorithm_suite: str, + authenticated_context_sha256: str, + plaintext_commitment: str, +) -> bytes: + return _canonical_bytes( + { + "context": "govoplan.local-content-key-wrap.v1", + "tenant_id": tenant_id, + "wrapped_key_ref": wrapped_key_ref, + "provider_key_ref": provider_key_ref, + "algorithm_suite": algorithm_suite, + "authenticated_context_sha256": authenticated_context_sha256, + "plaintext_commitment": plaintext_commitment, + } + ) + + +def _canonical_bytes(value: Mapping[str, Any]) -> bytes: + return json.dumps( + dict(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + + +def _digest(value: Mapping[str, Any]) -> str: + return _sha256(_canonical_bytes(value)) + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _now() -> datetime: + return datetime.now(tz=UTC) + + +def _aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +__all__ = [ + "LOCAL_ALGORITHM_SUITE", + "LOCAL_PROVIDER_ID", + "LOCAL_PROVIDER_VERSION", + "LocalAesGcmProvider", + "LocalProviderConfigurationError", + "LocalProviderError", +] diff --git a/src/govoplan_encryption/backend/manifest.py b/src/govoplan_encryption/backend/manifest.py index 08737bf..c41da3a 100644 --- a/src/govoplan_encryption/backend/manifest.py +++ b/src/govoplan_encryption/backend/manifest.py @@ -3,8 +3,11 @@ from __future__ import annotations from pathlib import Path from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, + CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX, CAPABILITY_ENCRYPTION_KEY_VAULT, CAPABILITY_ENCRYPTION_RECOVERY, ) @@ -27,6 +30,10 @@ from govoplan_core.core.modules import ( from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.db.base import Base from govoplan_encryption.backend.db import models +from govoplan_encryption.backend.local_provider import ( + LOCAL_PROVIDER_ID, + LocalAesGcmProvider, +) from govoplan_encryption.backend.service import SqlEncryptionService @@ -75,6 +82,10 @@ def _service(context: ModuleContext) -> SqlEncryptionService: return SqlEncryptionService(context.registry) +def _local_provider(context: ModuleContext) -> LocalAesGcmProvider: + return LocalAesGcmProvider(getattr(context.settings, "master_key_b64", None)) + + def _disable_guard( session: object | None, _module_id: str, @@ -148,19 +159,31 @@ manifest = ModuleManifest( optional_dependencies=OPTIONAL_DEPENDENCIES, provides_interfaces=( ModuleInterfaceProvider( - name="encryption.key_vault", + name=CAPABILITY_ENCRYPTION_KEY_VAULT, version="1.0.0", ), ModuleInterfaceProvider( - name="encryption.content_protection", + name=CAPABILITY_ENCRYPTION_CONTENT_PROTECTION, version="1.0.0", ), ModuleInterfaceProvider( - name="encryption.recovery_ceremony", + name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER, version="1.0.0", ), ModuleInterfaceProvider( - name="encryption.disable_preflight", + name=f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", + version="1.0.0", + ), + ModuleInterfaceProvider( + name=f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", + version="1.0.0", + ), + ModuleInterfaceProvider( + name=CAPABILITY_ENCRYPTION_RECOVERY, + version="1.0.0", + ), + ModuleInterfaceProvider( + name=CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, version="1.0.0", ), ), @@ -170,8 +193,11 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_ENCRYPTION_KEY_VAULT: _service, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service, + CAPABILITY_ENCRYPTION_CONTENT_CIPHER: _service, CAPABILITY_ENCRYPTION_RECOVERY: _service, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: _service, + f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider, + f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider, }, capability_documentation={ CAPABILITY_ENCRYPTION_KEY_VAULT: CapabilityDocumentation( @@ -191,6 +217,15 @@ manifest = ModuleManifest( ), contract_version="1.0.0", ), + CAPABILITY_ENCRYPTION_CONTENT_CIPHER: CapabilityDocumentation( + label="Server-side content cipher", + summary=( + "Protects and opens owner-module content through opaque, " + "versioned envelopes without exporting key material." + ), + contract_version="1.0.0", + audience=("module_developer", "security_officer", "auditor"), + ), CAPABILITY_ENCRYPTION_RECOVERY: CapabilityDocumentation( label="Encryption recovery ceremony", summary=( @@ -218,6 +253,9 @@ manifest = ModuleManifest( models.RecoveryCeremony, models.ProtectionMigration, models.ContentProtectionRecord, + models.EncryptionLocalProviderOperation, + models.EncryptionLocalWrappedContentKey, + models.EncryptionLocalKeyMaterial, models.EncryptionKeyOperation, models.EncryptionKeyVersion, models.EncryptionVault, @@ -238,6 +276,9 @@ manifest = ModuleManifest( models.ProtectionMigration, models.RecoveryCeremony, models.RecoveryApproval, + models.EncryptionLocalKeyMaterial, + models.EncryptionLocalWrappedContentKey, + models.EncryptionLocalProviderOperation, label=MODULE_NAME, ), ), @@ -255,8 +296,9 @@ manifest = ModuleManifest( "implicitly grants cryptographic keys. High-risk lifecycle " "actions require recent Identity Trust assurance. Disabling is " "blocked until each envelope is migrated, decrypted, explicitly " - "exported, or cryptographically destroyed. No bundled provider " - "or E2EE claim is implied by enabling this module." + "exported, or cryptographically destroyed. The bundled local " + "AES-GCM provider is server-readable and requires the deployment " + "master key; it does not imply end-to-end encryption." ), layer="available", documentation_types=("admin", "user"), @@ -285,7 +327,7 @@ manifest = ModuleManifest( documentation_ref="docs/ENCRYPTION_BOUNDARY.md", test_ref="tests/test_encryption.py", known_limits=( - "The module orchestrates references and evidence but ships no concrete cryptographic provider, raw key store, cipher implementation, client E2EE protocol, KMS/HSM conformance suite, or production recovery executor.", + "The bundled local AES-256-GCM provider is a server-side reference provider backed by shared SQL state and MASTER_KEY_B64; it is not an HSM/KMS, client E2EE protocol, or independent certification.", "A true E2EE claim remains prohibited until a selected client/provider profile passes its threat model, interoperability fixtures, backup/restore tests, and independent review.", ), owned_concepts=( diff --git a/src/govoplan_encryption/backend/migrations/versions/e5b7c9d1f3a4_v0114_local_crypto_provider.py b/src/govoplan_encryption/backend/migrations/versions/e5b7c9d1f3a4_v0114_local_crypto_provider.py new file mode 100644 index 0000000..b79fbce --- /dev/null +++ b/src/govoplan_encryption/backend/migrations/versions/e5b7c9d1f3a4_v0114_local_crypto_provider.py @@ -0,0 +1,154 @@ +"""v0.1.14 local cryptographic provider state + +Revision ID: e5b7c9d1f3a4 +Revises: d4a6b8c0e2f3 +Create Date: 2026-08-02 00:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e5b7c9d1f3a4" +down_revision = "d4a6b8c0e2f3" +branch_labels = None +depends_on = None + + +def _timestamps() -> tuple[sa.Column, sa.Column]: + return ( + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def upgrade() -> None: + op.create_table( + "encryption_local_key_material", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("tenant_id", sa.String(36), nullable=False), + sa.Column("vault_id", sa.String(255), nullable=False), + sa.Column("key_version", sa.Integer(), nullable=False), + sa.Column("provider_key_ref", sa.String(255), nullable=False), + sa.Column("algorithm_suite", sa.String(120), nullable=False), + sa.Column("purpose", sa.String(255), nullable=False), + sa.Column("state", sa.String(40), nullable=False), + sa.Column("wrapped_key", sa.LargeBinary(), nullable=True), + sa.Column("wrap_nonce", sa.LargeBinary(), nullable=True), + sa.Column("wrap_context_sha256", sa.String(64), nullable=False), + sa.Column("provision_idempotency_key", sa.String(255), nullable=False), + sa.Column("provision_request_sha256", sa.String(64), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "destruction_scheduled_at", sa.DateTime(timezone=True), nullable=True + ), + sa.Column("destroyed_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.UniqueConstraint( + "provider_key_ref", name="uq_encryption_local_provider_key_ref" + ), + sa.UniqueConstraint( + "tenant_id", + "vault_id", + "key_version", + name="uq_encryption_local_vault_key_version", + ), + sa.UniqueConstraint( + "tenant_id", + "provision_idempotency_key", + name="uq_encryption_local_provision_idempotency", + ), + ) + for column in ("tenant_id", "vault_id", "provider_key_ref", "state"): + op.create_index( + f"ix_encryption_local_key_material_{column}", + "encryption_local_key_material", + [column], + ) + op.create_index( + "ix_encryption_local_key_state", + "encryption_local_key_material", + ["tenant_id", "state", "updated_at"], + ) + + op.create_table( + "encryption_local_wrapped_content_keys", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("tenant_id", sa.String(36), nullable=False), + sa.Column("wrapped_key_ref", sa.String(255), nullable=False), + sa.Column("provider_key_ref", sa.String(255), nullable=False), + sa.Column("algorithm_suite", sa.String(120), nullable=False), + sa.Column("state", sa.String(40), nullable=False), + sa.Column("wrapped_content_key", sa.LargeBinary(), nullable=True), + sa.Column("wrap_nonce", sa.LargeBinary(), nullable=True), + sa.Column("content_nonce", sa.LargeBinary(), nullable=True), + sa.Column("authenticated_context_sha256", sa.String(64), nullable=False), + sa.Column("plaintext_commitment", sa.String(64), nullable=False), + sa.Column("idempotency_key", sa.String(255), nullable=False), + sa.Column("request_sha256", sa.String(64), nullable=False), + sa.Column("source_wrapped_key_ref", sa.String(255), nullable=True), + sa.Column("destroyed_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.UniqueConstraint( + "wrapped_key_ref", name="uq_encryption_local_wrapped_key_ref" + ), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_encryption_local_content_idempotency", + ), + ) + for column in ( + "tenant_id", + "wrapped_key_ref", + "provider_key_ref", + "state", + ): + op.create_index( + f"ix_encryption_local_wrapped_content_keys_{column}", + "encryption_local_wrapped_content_keys", + [column], + ) + op.create_index( + "ix_encryption_local_content_state", + "encryption_local_wrapped_content_keys", + ["tenant_id", "state", "updated_at"], + ) + + op.create_table( + "encryption_local_provider_operations", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("tenant_id", sa.String(36), nullable=False), + sa.Column("operation", sa.String(50), nullable=False), + sa.Column("target_ref", sa.String(255), nullable=False), + sa.Column("outcome_ref", sa.String(255), nullable=True), + sa.Column("idempotency_key", sa.String(255), nullable=False), + sa.Column("request_sha256", sa.String(64), nullable=False), + sa.Column("state", sa.String(40), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + *_timestamps(), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_encryption_local_provider_operation_idem", + ), + ) + for column in ("tenant_id", "operation", "target_ref", "state"): + op.create_index( + f"ix_encryption_local_provider_operations_{column}", + "encryption_local_provider_operations", + [column], + ) + op.create_index( + "ix_encryption_local_provider_operation_state", + "encryption_local_provider_operations", + ["tenant_id", "state", "updated_at"], + ) + + +def downgrade() -> None: + op.drop_table("encryption_local_provider_operations") + op.drop_table("encryption_local_wrapped_content_keys") + op.drop_table("encryption_local_key_material") diff --git a/src/govoplan_encryption/backend/service.py b/src/govoplan_encryption/backend/service.py index 959c1ce..05d89d0 100644 --- a/src/govoplan_encryption/backend/service.py +++ b/src/govoplan_encryption/backend/service.py @@ -5,12 +5,19 @@ from collections.abc import Mapping from datetime import datetime, timezone import hashlib import json +from types import SimpleNamespace +import uuid from sqlalchemy import func, select from sqlalchemy.orm import Session from govoplan_core.core.encryption import ( + ContentCipherDecryptRequest, + ContentCipherEncryptRequest, + ContentKeyRewrapRequest, + ContentProtectionRequest, ContentProtectionEnvelope, + ContentUnprotectionRequest, DisablePreflightReport, KeyLifecycleRequest, KeyMaterialDescriptor, @@ -22,9 +29,11 @@ from govoplan_core.core.encryption import ( ProtectionMigrationRequest, ProtectionMigrationResult, ProtectionRegistrationRequest, + ProtectedContent, RecoveryApprovalRequest, RecoveryRef, RecoveryRequest, + content_cipher_provider, key_material_provider, ) from govoplan_core.core.identity_trust import ( @@ -331,6 +340,496 @@ class SqlEncryptionService: _require_tenant(principal, request.envelope.tenant_id) return self._register_envelope(db, principal, request=request) + def protect_content( + self, + session: object, + *, + request: ContentProtectionRequest, + ) -> ProtectedContent: + db = _session(session) + vault = self._vault( + db, + tenant_id=request.tenant_id, + vault_id=request.vault_id, + ) + if vault.profile_kind != "server_envelope": + raise EncryptionError( + "Server-side content protection requires a server-envelope vault." + ) + if vault.state != "active" or vault.current_key_version is None: + raise EncryptionError("The selected encryption vault is unavailable.") + key = self._key(db, vault, vault.current_key_version) + if key.state != "active" or key.provider_key_ref is None: + raise EncryptionError("The selected encryption key is unavailable.") + provider = content_cipher_provider(self.registry, vault.provider_id) + if provider is None: + raise EncryptionError( + f"Encryption content-cipher provider {vault.provider_id!r} is unavailable." + ) + context_binding = _content_context_binding( + tenant_id=request.tenant_id, + owner_module=request.owner_module, + resource_type=request.resource_type, + resource_id=request.resource_id, + profile_id=request.profile_id, + provider_id=vault.provider_id, + vault_id=vault.vault_id, + key_version=key.version, + algorithm_suite=key.algorithm_suite, + ) + authenticated_context = _content_authenticated_context(context_binding) + registration_key = f"protect:{request.idempotency_key}" + replayed = ( + db.scalar( + select(ContentProtectionRecord.id).where( + ContentProtectionRecord.tenant_id == request.tenant_id, + ContentProtectionRecord.idempotency_key == registration_key, + ) + ) + is not None + ) + encrypted = provider.encrypt_content( + db, + request=ContentCipherEncryptRequest( + tenant_id=request.tenant_id, + vault_id=vault.vault_id, + key_version=key.version, + provider_key_ref=key.provider_key_ref, + algorithm_suite=key.algorithm_suite, + plaintext=request.plaintext, + authenticated_context=authenticated_context, + idempotency_key=f"protect:{request.idempotency_key}", + ), + ) + if ( + encrypted.provider_id != vault.provider_id + or encrypted.provider_key_ref != key.provider_key_ref + or encrypted.algorithm_suite != key.algorithm_suite + ): + raise EncryptionError( + "The content provider returned material for another vault key." + ) + expected_context_digest = ( + f"sha256:{hashlib.sha256(authenticated_context).hexdigest()}" + ) + if encrypted.authenticated_context_digest != expected_context_digest: + raise EncryptionError( + "The content provider returned another authenticated context." + ) + envelope = ContentProtectionEnvelope( + envelope_id=str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"govoplan:encryption:{request.tenant_id}:{request.idempotency_key}", + ) + ), + 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=vault.provider_id, + vault_id=vault.vault_id, + key_version=key.version, + algorithm_suite=key.algorithm_suite, + ciphertext_ref=request.ciphertext_ref, + ciphertext_digest=encrypted.ciphertext_digest, + authenticated_context_digest=encrypted.authenticated_context_digest, + state="active", + created_at=encrypted.created_at, + wrapped_key_refs=(encrypted.wrapped_key_ref,), + metadata={ + **dict(request.metadata), + "cipher": dict(encrypted.provenance), + "context_binding": context_binding, + "server_readable": True, + }, + ) + principal = SimpleNamespace( + tenant_id=request.tenant_id, + account_id=request.actor_id or f"module:{request.owner_module}", + ) + registered = self._register_envelope( + db, + principal, + request=ProtectionRegistrationRequest( + envelope=envelope, + idempotency_key=registration_key, + policy_decision_ref=request.policy_decision_ref, + ), + ) + return ProtectedContent( + envelope=registered, + ciphertext=encrypted.ciphertext, + replayed=replayed, + ) + + def unprotect_content( + self, + session: object, + *, + request: ContentUnprotectionRequest, + ) -> bytes: + db = _session(session) + envelope = self._envelope( + db, + tenant_id=request.tenant_id, + envelope_id=request.envelope_id, + ) + if ( + envelope.owner_module != request.owner_module + or envelope.resource_type != request.resource_type + or envelope.resource_id != request.resource_id + ): + raise EncryptionError( + "The protection envelope belongs to another owning resource." + ) + if envelope.state not in {"active", "migration_pending", "migrating"}: + raise EncryptionError("Protected content is not available in this state.") + if envelope.profile_kind != "server_envelope": + raise EncryptionError( + "This server cannot decrypt a tenant-held or end-to-end envelope." + ) + if len(envelope.wrapped_key_refs) != 1: + raise EncryptionError( + "Server-envelope content requires exactly one wrapped content key." + ) + vault = self._vault( + db, + tenant_id=request.tenant_id, + vault_id=envelope.vault_id, + ) + key = self._key(db, vault, envelope.key_version) + if key.provider_key_ref is None: + raise EncryptionError("The envelope vault key is unavailable.") + provider = content_cipher_provider(self.registry, envelope.provider_id) + if provider is None: + raise EncryptionError( + f"Encryption content-cipher provider {envelope.provider_id!r} is unavailable." + ) + binding = envelope.envelope_metadata.get("context_binding") + if isinstance(binding, Mapping): + authenticated_context = _content_authenticated_context(binding) + else: + authenticated_context = _content_authenticated_context( + _content_context_binding( + tenant_id=envelope.tenant_id, + owner_module=envelope.owner_module, + resource_type=envelope.resource_type, + resource_id=envelope.resource_id, + profile_id=envelope.profile_id, + provider_id=envelope.provider_id, + vault_id=envelope.vault_id, + key_version=envelope.key_version, + algorithm_suite=envelope.algorithm_suite, + ) + ) + result = provider.decrypt_content( + db, + request=ContentCipherDecryptRequest( + tenant_id=request.tenant_id, + provider_key_ref=key.provider_key_ref, + wrapped_key_ref=envelope.wrapped_key_refs[0], + algorithm_suite=envelope.algorithm_suite, + ciphertext=request.ciphertext, + ciphertext_digest=envelope.ciphertext_digest, + authenticated_context=authenticated_context, + ), + ) + return result.plaintext + + def execute_rewrap( + self, + session: object, + principal: object, + *, + migration_id: str, + ) -> ProtectionMigrationResult: + db = _session(session) + migration = db.scalar( + select(ProtectionMigration) + .where(ProtectionMigration.id == migration_id) + .with_for_update() + ) + if migration is None: + raise EncryptionError("Protection migration not found.") + _require_tenant(principal, migration.tenant_id) + if migration.mode != "rewrap": + raise EncryptionError("Only a rewrap migration can use this executor.") + if migration.state == "succeeded": + return self._migration_ref(db, migration) + if migration.state not in {"requested", "outcome_unknown"}: + raise EncryptionError( + f"Rewrap cannot execute from state {migration.state}." + ) + source = self._envelope( + db, + tenant_id=migration.tenant_id, + envelope_id=migration.source_envelope_id, + lock=True, + ) + if len(source.wrapped_key_refs) != 1: + raise EncryptionError( + "Server-envelope rewrap requires exactly one wrapped content key." + ) + if source.provider_id != migration.target_provider_id: + raise EncryptionError( + "Cross-provider migration requires re-encryption, not rewrap." + ) + source_vault = self._vault( + db, + tenant_id=source.tenant_id, + vault_id=source.vault_id, + ) + source_key = self._key(db, source_vault, source.key_version) + target_vault = self._vault( + db, + tenant_id=migration.tenant_id, + vault_id=migration.target_vault_id, + ) + target_key = self._key(db, target_vault, migration.target_key_version) + if source_key.provider_key_ref is None or target_key.provider_key_ref is None: + raise EncryptionError("A rewrap vault key is unavailable.") + provider = content_cipher_provider(self.registry, source.provider_id) + if provider is None: + raise EncryptionError( + f"Encryption content-cipher provider {source.provider_id!r} is unavailable." + ) + migration.state = "running" + db.flush() + try: + rewrapped = provider.rewrap_content_key( + db, + request=ContentKeyRewrapRequest( + tenant_id=source.tenant_id, + source_provider_key_ref=source_key.provider_key_ref, + source_wrapped_key_ref=source.wrapped_key_refs[0], + target_provider_key_ref=target_key.provider_key_ref, + algorithm_suite=migration.target_algorithm_suite, + idempotency_key=f"migration:{migration.id}:rewrap", + ), + ) + target_envelope = ContentProtectionEnvelope( + envelope_id=str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"govoplan:encryption:migration:{migration.id}:target", + ) + ), + tenant_id=source.tenant_id, + owner_module=source.owner_module, + resource_type=source.resource_type, + resource_id=source.resource_id, + profile_kind=source.profile_kind, # type: ignore[arg-type] + profile_id=source.profile_id, + provider_id=migration.target_provider_id, + vault_id=migration.target_vault_id, + key_version=migration.target_key_version, + algorithm_suite=migration.target_algorithm_suite, + ciphertext_ref=source.ciphertext_ref, + ciphertext_digest=source.ciphertext_digest, + authenticated_context_digest=source.authenticated_context_digest, + state="active", + created_at=rewrapped.completed_at, + wrapped_key_refs=(rewrapped.target_wrapped_key_ref,), + source_envelope_id=source.envelope_id, + migration_id=migration.id, + metadata={ + **dict(source.envelope_metadata), + "rewrap": dict(rewrapped.provenance), + }, + ) + return self.record_migration_outcome( + db, + principal, + migration_id=migration.id, + state="succeeded", + evidence_refs=( + f"local-provider-rewrap:{rewrapped.target_wrapped_key_ref}", + ), + target_envelope=target_envelope, + ) + except Exception as exc: + migration.state = "outcome_unknown" + migration.error_code = _exception_code(exc) + migration.provenance = { + **dict(migration.provenance), + "executor": "content_cipher", + "error_disclosed": False, + } + source.state = "migration_pending" + db.flush() + raise + + def prepare_reencryption( + self, + session: object, + principal: object, + *, + migration_id: str, + source_ciphertext: bytes, + target_ciphertext_ref: str, + ) -> ProtectedContent: + """Prepare target bytes; the owner records success after durable storage.""" + + db = _session(session) + migration = db.scalar( + select(ProtectionMigration) + .where(ProtectionMigration.id == migration_id) + .with_for_update() + ) + if migration is None: + raise EncryptionError("Protection migration not found.") + _require_tenant(principal, migration.tenant_id) + if migration.mode != "reencrypt": + raise EncryptionError( + "Only a re-encryption migration can use this executor." + ) + if migration.state == "succeeded": + raise EncryptionError( + "The completed migration target must be read from its result." + ) + if migration.state not in {"requested", "running", "outcome_unknown"}: + raise EncryptionError( + f"Re-encryption cannot execute from state {migration.state}." + ) + clean_ciphertext_ref = str(target_ciphertext_ref or "").strip() + if not clean_ciphertext_ref: + raise EncryptionError("A target ciphertext reference is required.") + replayed = migration.state in {"running", "outcome_unknown"} + previous_target_ref = str( + (migration.provenance or {}).get("target_ciphertext_ref") or "" + ).strip() + if previous_target_ref and previous_target_ref != clean_ciphertext_ref: + raise EncryptionError( + "The re-encryption migration is already bound to another target ciphertext reference." + ) + source = self._envelope( + db, + tenant_id=migration.tenant_id, + envelope_id=migration.source_envelope_id, + lock=True, + ) + plaintext = self.unprotect_content( + db, + request=ContentUnprotectionRequest( + tenant_id=source.tenant_id, + owner_module=source.owner_module, + resource_type=source.resource_type, + resource_id=source.resource_id, + envelope_id=source.envelope_id, + ciphertext=source_ciphertext, + actor_id=_account_id(principal), + ), + ) + target_vault = self._vault( + db, + tenant_id=migration.tenant_id, + vault_id=migration.target_vault_id, + ) + target_key = self._key(db, target_vault, migration.target_key_version) + if ( + target_vault.provider_id != migration.target_provider_id + or target_vault.profile_kind != source.profile_kind + or target_key.state != "active" + or target_key.provider_key_ref is None + or target_key.algorithm_suite != migration.target_algorithm_suite + ): + raise EncryptionError( + "The authorized re-encryption target is unavailable or incompatible." + ) + provider = content_cipher_provider(self.registry, migration.target_provider_id) + if provider is None: + raise EncryptionError( + f"Encryption content-cipher provider {migration.target_provider_id!r} is unavailable." + ) + context_binding = _content_context_binding( + tenant_id=source.tenant_id, + owner_module=source.owner_module, + resource_type=source.resource_type, + resource_id=source.resource_id, + profile_id=source.profile_id, + provider_id=migration.target_provider_id, + vault_id=migration.target_vault_id, + key_version=migration.target_key_version, + algorithm_suite=migration.target_algorithm_suite, + ) + authenticated_context = _content_authenticated_context(context_binding) + encrypted = provider.encrypt_content( + db, + request=ContentCipherEncryptRequest( + tenant_id=source.tenant_id, + vault_id=migration.target_vault_id, + key_version=migration.target_key_version, + provider_key_ref=target_key.provider_key_ref, + algorithm_suite=migration.target_algorithm_suite, + plaintext=plaintext, + authenticated_context=authenticated_context, + idempotency_key=f"migration:{migration.id}:reencrypt", + ), + ) + expected_context_digest = ( + f"sha256:{hashlib.sha256(authenticated_context).hexdigest()}" + ) + if ( + encrypted.provider_id != migration.target_provider_id + or encrypted.provider_key_ref != target_key.provider_key_ref + or encrypted.algorithm_suite != migration.target_algorithm_suite + or encrypted.authenticated_context_digest != expected_context_digest + ): + raise EncryptionError( + "The re-encryption provider returned material for another target." + ) + target_envelope = ContentProtectionEnvelope( + envelope_id=str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"govoplan:encryption:migration:{migration.id}:target", + ) + ), + tenant_id=source.tenant_id, + owner_module=source.owner_module, + resource_type=source.resource_type, + resource_id=source.resource_id, + profile_kind=source.profile_kind, # type: ignore[arg-type] + profile_id=source.profile_id, + provider_id=migration.target_provider_id, + vault_id=migration.target_vault_id, + key_version=migration.target_key_version, + algorithm_suite=migration.target_algorithm_suite, + ciphertext_ref=clean_ciphertext_ref, + ciphertext_digest=encrypted.ciphertext_digest, + authenticated_context_digest=encrypted.authenticated_context_digest, + state="active", + created_at=encrypted.created_at, + wrapped_key_refs=(encrypted.wrapped_key_ref,), + source_envelope_id=source.envelope_id, + migration_id=migration.id, + metadata={ + **dict(source.envelope_metadata), + "cipher": dict(encrypted.provenance), + "context_binding": context_binding, + "migration_mode": "reencrypt", + "server_readable": True, + }, + ) + migration.state = "running" + migration.provenance = { + **dict(migration.provenance), + "executor": "content_cipher", + "target_ciphertext_ref": clean_ciphertext_ref, + "target_ciphertext_sha256": encrypted.ciphertext_digest, + "owner_confirmation_required": True, + } + source.state = "migrating" + db.flush() + return ProtectedContent( + envelope=target_envelope, + ciphertext=encrypted.ciphertext, + replayed=replayed, + ) + def get_envelope( self, session: object, @@ -457,13 +956,21 @@ class SqlEncryptionService: clean_evidence = tuple( value.strip() for value in evidence_refs if value.strip() ) - if item.state in {"succeeded", "rejected", "outcome_unknown"}: + if item.state in {"succeeded", "rejected"}: if ( item.state != normalized_state or tuple(item.evidence_refs) != clean_evidence ): raise EncryptionError("The migration already has another outcome.") return self._migration_ref(db, item) + if item.state == "outcome_unknown" and normalized_state == "outcome_unknown": + if tuple( + item.evidence_refs + ) != clean_evidence or item.error_code != _safe_error_code(error_code): + raise EncryptionError( + "The unresolved migration already has different evidence." + ) + return self._migration_ref(db, item) if normalized_state == "succeeded" and not clean_evidence: raise EncryptionError( "A successful migration requires evidence references." @@ -1406,6 +1913,57 @@ def _envelope_payload( } +def _content_context_binding( + *, + tenant_id: str, + owner_module: str, + resource_type: str, + resource_id: str, + profile_id: str, + provider_id: str, + vault_id: str, + key_version: int, + algorithm_suite: str, +) -> dict[str, object]: + return { + "tenant_id": tenant_id, + "owner_module": owner_module, + "resource_type": resource_type, + "resource_id": resource_id, + "profile_id": profile_id, + "provider_id": provider_id, + "vault_id": vault_id, + "key_version": key_version, + "algorithm_suite": algorithm_suite, + } + + +def _content_authenticated_context(binding: Mapping[str, object]) -> bytes: + required = ( + "tenant_id", + "owner_module", + "resource_type", + "resource_id", + "profile_id", + "provider_id", + "vault_id", + "key_version", + "algorithm_suite", + ) + if any(binding.get(name) in (None, "") for name in required): + raise EncryptionError("Protection envelope context binding is incomplete.") + payload = { + "context": "govoplan.content-protection.v1", + **{name: binding[name] for name in required}, + } + return json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + + def _expected_revision(item: object, expected: int) -> None: actual = int(getattr(item, "revision", 0)) if actual != expected: diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py new file mode 100644 index 0000000..1fbda36 --- /dev/null +++ b/tests/test_local_provider.py @@ -0,0 +1,471 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import hashlib +import unittest + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX, + CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX, + ContentProtectionRequest, + ContentUnprotectionRequest, + KeyRotationRequest, + KeyVaultCreateRequest, + ProtectionMigrationRequest, +) +from govoplan_core.core.identity_trust import ( + CAPABILITY_IDENTITY_TRUST_ASSURANCE, + AssuranceDecision, +) +from govoplan_encryption.backend.db.models import ( + ContentProtectionRecord, + EncryptionKeyOperation, + EncryptionKeyVersion, + EncryptionLocalKeyMaterial, + EncryptionLocalProviderOperation, + EncryptionLocalWrappedContentKey, + EncryptionVault, + ProtectionMigration, + RecoveryApproval, + RecoveryCeremony, +) +from govoplan_encryption.backend.local_provider import ( + LOCAL_PROVIDER_ID, + LocalAesGcmProvider, + LocalProviderError, +) +from govoplan_encryption.backend.service import SqlEncryptionService + + +class Principal: + tenant_id = "tenant-1" + account_id = "account-1" + + +class Assurance: + def verify_assurance(self, _session, _principal, *, request): + return AssuranceDecision( + allowed=True, + reason="test assurance", + assurance_level="high", + evidence_ref=request.evidence_ref, + verified_at=request.evaluated_at, + expires_at=request.evaluated_at + timedelta(minutes=5), + provenance={"provider_id": "test"}, + ) + + +class Registry: + def __init__(self, provider: LocalAesGcmProvider) -> None: + self.values = { + f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": provider, + f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": provider, + CAPABILITY_IDENTITY_TRUST_ASSURANCE: Assurance(), + } + + def has_capability(self, name: str) -> bool: + return name in self.values + + def capability(self, name: str): + return self.values[name] + + +class SequentialReferences: + def __init__(self) -> None: + self.value = 0 + + def __call__(self) -> str: + self.value += 1 + return f"reference-{self.value}" + + +class LocalProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + self.tables = [ + EncryptionVault.__table__, + EncryptionKeyVersion.__table__, + EncryptionKeyOperation.__table__, + EncryptionLocalKeyMaterial.__table__, + EncryptionLocalWrappedContentKey.__table__, + EncryptionLocalProviderOperation.__table__, + ContentProtectionRecord.__table__, + ProtectionMigration.__table__, + RecoveryCeremony.__table__, + RecoveryApproval.__table__, + ] + for table in self.tables: + table.create(self.engine) + self.session = Session(self.engine) + self.provider = LocalAesGcmProvider( + bytes(range(32)), + random_bytes=lambda size: bytes([size]) * size, + reference_factory=SequentialReferences(), + ) + self.registry = Registry(self.provider) + self.service = SqlEncryptionService(self.registry) + self.principal = Principal() + self._create_vault() + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def _create_vault(self) -> None: + result = self.service.create_vault( + self.session, + self.principal, + request=KeyVaultCreateRequest( + tenant_id="tenant-1", + vault_id="vault-1", + name="Reference content vault", + provider_id=LOCAL_PROVIDER_ID, + purpose="feature-content", + algorithm_suite="AES-256-GCM", + scope_type="tenant", + scope_id="tenant-1", + policy_ref="policy:encryption:v1", + idempotency_key="create-vault-1", + ), + ) + self.assertEqual("active", result.state) + + def _protect(self, *, idempotency_key: str = "protect-1"): + return self.service.protect_content( + self.session, + request=ContentProtectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + profile_id="server-envelope-v1", + vault_id="vault-1", + ciphertext_ref="object://files/blob-1", + plaintext=b"sensitive monthly records", + policy_decision_ref="policy:protect:v1", + idempotency_key=idempotency_key, + actor_id="account-1", + metadata={"content_type": "text/plain"}, + ), + ) + + def _open(self, protected) -> bytes: + return self.service.unprotect_content( + self.session, + request=ContentUnprotectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + envelope_id=protected.envelope.envelope_id, + ciphertext=protected.ciphertext, + actor_id="account-1", + ), + ) + + def test_round_trip_uses_wrapped_keys_and_rejects_tampering(self) -> None: + protected = self._protect() + self.assertNotIn(b"sensitive monthly records", protected.ciphertext) + self.assertEqual( + "1c4d240db81a06892585447218d83e0fbc53d5d346206d758351b2af635cbe01108699e93edf4057c8", + protected.ciphertext.hex(), + ) + self.assertEqual(b"sensitive monthly records", self._open(protected)) + + key = self.session.scalar(select(EncryptionLocalKeyMaterial)) + wrapped = self.session.scalar(select(EncryptionLocalWrappedContentKey)) + self.assertIsNotNone(key.wrapped_key) + self.assertIsNotNone(wrapped.wrapped_content_key) + self.assertNotEqual(bytes(range(32)), key.wrapped_key) + self.assertNotEqual( + hashlib.sha256(b"sensitive monthly records").hexdigest(), + wrapped.plaintext_commitment, + ) + + corrupted = bytearray(protected.ciphertext) + corrupted[-1] ^= 1 + with self.assertRaisesRegex(LocalProviderError, "digest verification"): + self.service.unprotect_content( + self.session, + request=ContentUnprotectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + envelope_id=protected.envelope.envelope_id, + ciphertext=bytes(corrupted), + ), + ) + + def test_idempotent_replay_returns_identical_ciphertext(self) -> None: + first = self._protect() + replay = self._protect() + self.assertTrue(replay.replayed) + self.assertEqual(first.envelope.envelope_id, replay.envelope.envelope_id) + self.assertEqual(first.ciphertext, replay.ciphertext) + self.assertEqual( + 1, + self.session.query(EncryptionLocalWrappedContentKey).count(), + ) + + def test_rotation_rewraps_without_changing_ciphertext(self) -> None: + protected = self._protect() + rotated = self.service.rotate_key( + self.session, + self.principal, + request=KeyRotationRequest( + tenant_id="tenant-1", + vault_id="vault-1", + expected_revision=1, + reason="scheduled rotation", + policy_decision_ref="policy:rotate:v1", + assurance_evidence_ref="assurance:rotate:v1", + idempotency_key="rotate-vault-1", + ), + ) + self.assertEqual(2, rotated.current_key.version) + migration = self.service.request_migration( + self.session, + self.principal, + request=ProtectionMigrationRequest( + tenant_id="tenant-1", + envelope_id=protected.envelope.envelope_id, + target_provider_id=LOCAL_PROVIDER_ID, + target_vault_id="vault-1", + target_key_version=2, + target_algorithm_suite="AES-256-GCM", + mode="rewrap", + policy_decision_ref="policy:rewrap:v1", + assurance_evidence_ref="assurance:rewrap:v1", + idempotency_key="rewrap-1", + ), + ) + result = self.service.execute_rewrap( + self.session, + self.principal, + migration_id=migration.migration_id, + ) + self.assertEqual("succeeded", result.state) + self.assertEqual( + protected.envelope.ciphertext_digest, + result.target_envelope.ciphertext_digest, + ) + opened = self.service.unprotect_content( + self.session, + request=ContentUnprotectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + envelope_id=result.target_envelope.envelope_id, + ciphertext=protected.ciphertext, + ), + ) + self.assertEqual(b"sensitive monthly records", opened) + + def test_reencryption_waits_for_owner_confirmation_and_replays_target(self) -> None: + protected = self._protect() + rotated = self.service.rotate_key( + self.session, + self.principal, + request=KeyRotationRequest( + tenant_id="tenant-1", + vault_id="vault-1", + expected_revision=1, + reason="algorithm-preserving content rotation", + policy_decision_ref="policy:rotate:v1", + assurance_evidence_ref="assurance:rotate:v1", + idempotency_key="rotate-vault-for-reencrypt", + ), + ) + migration = self.service.request_migration( + self.session, + self.principal, + request=ProtectionMigrationRequest( + tenant_id="tenant-1", + envelope_id=protected.envelope.envelope_id, + target_provider_id=LOCAL_PROVIDER_ID, + target_vault_id="vault-1", + target_key_version=rotated.current_key.version, + target_algorithm_suite="AES-256-GCM", + mode="reencrypt", + policy_decision_ref="policy:reencrypt:v1", + assurance_evidence_ref="assurance:reencrypt:v1", + idempotency_key="reencrypt-1", + ), + ) + + prepared = self.service.prepare_reencryption( + self.session, + self.principal, + migration_id=migration.migration_id, + source_ciphertext=protected.ciphertext, + target_ciphertext_ref="object://files/blob-1-v2", + ) + replay = self.service.prepare_reencryption( + self.session, + self.principal, + migration_id=migration.migration_id, + source_ciphertext=protected.ciphertext, + target_ciphertext_ref="object://files/blob-1-v2", + ) + self.assertNotEqual(protected.ciphertext, prepared.ciphertext) + self.assertTrue(replay.replayed) + self.assertEqual(prepared.ciphertext, replay.ciphertext) + self.assertIsNone( + self.service.get_envelope( + self.session, + self.principal, + tenant_id="tenant-1", + envelope_id=prepared.envelope.envelope_id, + ) + ) + + unresolved = self.service.record_migration_outcome( + self.session, + self.principal, + migration_id=migration.migration_id, + state="outcome_unknown", + evidence_refs=("owner-store:write-outcome-unknown",), + error_code="storage_timeout", + ) + self.assertEqual("outcome_unknown", unresolved.state) + prepared = self.service.prepare_reencryption( + self.session, + self.principal, + migration_id=migration.migration_id, + source_ciphertext=protected.ciphertext, + target_ciphertext_ref="object://files/blob-1-v2", + ) + self.assertTrue(prepared.replayed) + + completed = self.service.record_migration_outcome( + self.session, + self.principal, + migration_id=migration.migration_id, + state="succeeded", + evidence_refs=("owner-store:files:blob-1-v2",), + target_envelope=prepared.envelope, + ) + self.assertEqual("succeeded", completed.state) + opened = self.service.unprotect_content( + self.session, + request=ContentUnprotectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + envelope_id=prepared.envelope.envelope_id, + ciphertext=prepared.ciphertext, + ), + ) + self.assertEqual(b"sensitive monthly records", opened) + + def test_database_restore_requires_the_matching_deployment_key(self) -> None: + protected = self._protect() + self.session.commit() + restored_engine = create_engine("sqlite+pysqlite:///:memory:") + source = self.engine.raw_connection() + target = restored_engine.raw_connection() + try: + source.driver_connection.backup(target.driver_connection) + target.commit() + finally: + target.close() + source.close() + restored_session = Session(restored_engine) + restored_service = SqlEncryptionService( + Registry(LocalAesGcmProvider(bytes(range(32)))) + ) + request = ContentUnprotectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-1", + envelope_id=protected.envelope.envelope_id, + ciphertext=protected.ciphertext, + ) + self.assertEqual( + b"sensitive monthly records", + restored_service.unprotect_content(restored_session, request=request), + ) + wrong_service = SqlEncryptionService( + Registry(LocalAesGcmProvider(bytes(reversed(range(32))))) + ) + try: + with self.assertRaisesRegex( + LocalProviderError, + "configured deployment key", + ): + wrong_service.unprotect_content(restored_session, request=request) + finally: + restored_session.close() + restored_engine.dispose() + + def test_destroyed_vault_key_fails_closed(self) -> None: + protected = self._protect() + key = self.session.scalar(select(EncryptionLocalKeyMaterial)) + self.provider.schedule_key_destruction( + self.session, + self.principal, + provider_key_ref=key.provider_key_ref, + effective_at=datetime.now(tz=UTC) - timedelta(seconds=1), + idempotency_key="destroy-vault-material-1", + ) + with self.assertRaisesRegex(LocalProviderError, "unavailable"): + self._open(protected) + + def test_injected_encrypt_failure_rolls_back_without_mixed_state(self) -> None: + def fail(point: str) -> None: + if point == "encrypt.after_wrap": + raise RuntimeError("injected provider failure") + + failed_service = SqlEncryptionService( + Registry( + LocalAesGcmProvider( + bytes(range(32)), + fault_injector=fail, + ) + ) + ) + with self.assertRaisesRegex(RuntimeError, "injected provider failure"): + failed_service.protect_content( + self.session, + request=ContentProtectionRequest( + tenant_id="tenant-1", + owner_module="files", + resource_type="file_blob", + resource_id="blob-failed", + profile_id="server-envelope-v1", + vault_id="vault-1", + ciphertext_ref="object://files/blob-failed", + plaintext=b"must not become mixed state", + policy_decision_ref="policy:protect:v1", + idempotency_key="protect-failed", + actor_id="account-1", + ), + ) + self.session.rollback() + self.assertEqual( + 0, + self.session.query(EncryptionLocalWrappedContentKey) + .filter( + EncryptionLocalWrappedContentKey.idempotency_key + == "protect:protect-failed" + ) + .count(), + ) + self.assertEqual( + 0, + self.session.query(ContentProtectionRecord) + .filter(ContentProtectionRecord.resource_id == "blob-failed") + .count(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index bbf90dd..e947d59 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -3,12 +3,16 @@ from __future__ import annotations import unittest from govoplan_core.core.encryption import ( + CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, + CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX, CAPABILITY_ENCRYPTION_KEY_VAULT, CAPABILITY_ENCRYPTION_RECOVERY, ) from govoplan_encryption.backend.manifest import get_manifest +from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID class EncryptionManifestTests(unittest.TestCase): @@ -21,8 +25,11 @@ class EncryptionManifestTests(unittest.TestCase): { CAPABILITY_ENCRYPTION_KEY_VAULT, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION, + CAPABILITY_ENCRYPTION_CONTENT_CIPHER, CAPABILITY_ENCRYPTION_RECOVERY, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, + f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", + f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", }, set(manifest.capability_factories), ) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 1b4f413..1c71374 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -32,11 +32,14 @@ class EncryptionMigrationTests(unittest.TestCase): "encryption_protection_migrations", "encryption_recovery_ceremonies", "encryption_recovery_approvals", + "encryption_local_key_material", + "encryption_local_wrapped_content_keys", + "encryption_local_provider_operations", }.issubset(tables) ) with engine.connect() as connection: self.assertIn( - "d4a6b8c0e2f3", + "e5b7c9d1f3a4", set(MigrationContext.configure(connection).get_current_heads()), ) finally: