feat: add governed local content encryption provider

This commit is contained in:
2026-08-02 03:40:50 +02:00
parent 858c41d5ad
commit 42f35f8d00
11 changed files with 2318 additions and 26 deletions
+17 -8
View File
@@ -20,16 +20,25 @@ recovery ceremonies, and disable/uninstall assurance.
state transitions; state transitions;
- recent high-assurance, distinct-custodian quorum recovery authorization; - recent high-assurance, distinct-custodian quorum recovery authorization;
- typed APIs, audit-safe events, Alembic migration, and uninstall blocking; - 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 The bundled provider derives a deployment wrapping key from `MASTER_KEY_B64` and
key, KMS/HSM driver, client E2EE protocol, or provider credentials. A deployment keeps vault and per-content keys wrapped in shared SQL state. It provides real
must install a separately reviewed provider capability. Until a concrete profile authenticated encryption at rest, but the application server can decrypt and
passes conformance, backup/restore, key-loss, interoperability, and security therefore this is not E2EE, an HSM/KMS assurance claim, or independent
review, GovOPlaN must not claim that content is encrypted or end-to-end certification. Production backup and restore must preserve the database,
encrypted. 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 Feature modules continue to own content, authorization, retention, and resource
ownership. Access approval, resource ownership, Identity Trust, and key custody ownership. Access approval, resource ownership, Identity Trust, and key custody
+48 -8
View File
@@ -5,11 +5,16 @@
`govoplan-encryption` is the optional governance and lifecycle boundary for `govoplan-encryption` is the optional governance and lifecycle boundary for
cryptographic protection. It persists opaque provider references, versioned cryptographic protection. It persists opaque provider references, versioned
content-protection envelopes, idempotent operation state, recovery decisions, content-protection envelopes, idempotent operation state, recovery decisions,
and disable-preflight evidence. It does not ship a cipher, a raw key store, a and disable-preflight evidence. It ships a bounded `local_aesgcm` server-envelope
KMS/HSM driver, or a client E2EE protocol. 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 Selecting the bundled profile is an authenticated encryption-at-rest claim for
may make such a claim only for a named protection profile whose concrete 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 provider, clients, algorithms, backup procedure, and failure tests have passed
the profile's conformance and security review. the profile's conformance and security review.
@@ -72,6 +77,31 @@ encryption hides this metadata.
## Deployment Profiles ## 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 ### Server-side envelope encryption
The application or a deployment KMS/HSM can unwrap data keys after normal 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 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 never treated as success. Reconciliation repeats the exact idempotent provider
request and records only bounded error class codes. 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 Content migrations are explicit records. Rewrap and re-encryption succeed only
when an exact target envelope references the authorized source and migration. 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 Decrypt, export, and destruction require evidence references. Until an outcome
is known, the source remains `migration_pending`; no implicit plaintext fallback is known, the source remains `migration_pending`; no implicit plaintext fallback
is allowed. 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 an encrypted record is encountered without the capability. Unencrypted module
combinations continue to operate without Encryption. combinations continue to operate without Encryption.
Focused fixtures currently validate Files- and Postbox-owned envelope records. Focused fixtures validate Files and Postbox owner adapters: plaintext operation
Concrete feature adapters and production provider conformance remain tracked continues without Encryption, configured encrypted writes store ciphertext and
work; direct imports of Encryption database models from feature modules are not an envelope link, encrypted reads fail closed when the capability is absent,
allowed. 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.
+1 -1
View File
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"] dependencies = ["cryptography>=44", "govoplan-core>=0.1.14"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -9,6 +9,7 @@ from sqlalchemy import (
Index, Index,
Integer, Integer,
JSON, JSON,
LargeBinary,
String, String,
Text, Text,
UniqueConstraint, 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): class ContentProtectionRecord(Base, TimestampMixin):
__tablename__ = "encryption_content_protections" __tablename__ = "encryption_content_protections"
__table_args__ = ( __table_args__ = (
@@ -305,6 +442,9 @@ __all__ = [
"ContentProtectionRecord", "ContentProtectionRecord",
"EncryptionKeyOperation", "EncryptionKeyOperation",
"EncryptionKeyVersion", "EncryptionKeyVersion",
"EncryptionLocalKeyMaterial",
"EncryptionLocalProviderOperation",
"EncryptionLocalWrappedContentKey",
"EncryptionVault", "EncryptionVault",
"ProtectionMigration", "ProtectionMigration",
"RecoveryApproval", "RecoveryApproval",
@@ -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",
]
+49 -7
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.encryption import ( from govoplan_core.core.encryption import (
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_VAULT, CAPABILITY_ENCRYPTION_KEY_VAULT,
CAPABILITY_ENCRYPTION_RECOVERY, 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.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_encryption.backend.db import models 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 from govoplan_encryption.backend.service import SqlEncryptionService
@@ -75,6 +82,10 @@ def _service(context: ModuleContext) -> SqlEncryptionService:
return SqlEncryptionService(context.registry) return SqlEncryptionService(context.registry)
def _local_provider(context: ModuleContext) -> LocalAesGcmProvider:
return LocalAesGcmProvider(getattr(context.settings, "master_key_b64", None))
def _disable_guard( def _disable_guard(
session: object | None, session: object | None,
_module_id: str, _module_id: str,
@@ -148,19 +159,31 @@ manifest = ModuleManifest(
optional_dependencies=OPTIONAL_DEPENDENCIES, optional_dependencies=OPTIONAL_DEPENDENCIES,
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider( ModuleInterfaceProvider(
name="encryption.key_vault", name=CAPABILITY_ENCRYPTION_KEY_VAULT,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider( ModuleInterfaceProvider(
name="encryption.content_protection", name=CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider( ModuleInterfaceProvider(
name="encryption.recovery_ceremony", name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider( 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", version="1.0.0",
), ),
), ),
@@ -170,8 +193,11 @@ manifest = ModuleManifest(
capability_factories={ capability_factories={
CAPABILITY_ENCRYPTION_KEY_VAULT: _service, CAPABILITY_ENCRYPTION_KEY_VAULT: _service,
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service,
CAPABILITY_ENCRYPTION_CONTENT_CIPHER: _service,
CAPABILITY_ENCRYPTION_RECOVERY: _service, CAPABILITY_ENCRYPTION_RECOVERY: _service,
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: _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_documentation={
CAPABILITY_ENCRYPTION_KEY_VAULT: CapabilityDocumentation( CAPABILITY_ENCRYPTION_KEY_VAULT: CapabilityDocumentation(
@@ -191,6 +217,15 @@ manifest = ModuleManifest(
), ),
contract_version="1.0.0", 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( CAPABILITY_ENCRYPTION_RECOVERY: CapabilityDocumentation(
label="Encryption recovery ceremony", label="Encryption recovery ceremony",
summary=( summary=(
@@ -218,6 +253,9 @@ manifest = ModuleManifest(
models.RecoveryCeremony, models.RecoveryCeremony,
models.ProtectionMigration, models.ProtectionMigration,
models.ContentProtectionRecord, models.ContentProtectionRecord,
models.EncryptionLocalProviderOperation,
models.EncryptionLocalWrappedContentKey,
models.EncryptionLocalKeyMaterial,
models.EncryptionKeyOperation, models.EncryptionKeyOperation,
models.EncryptionKeyVersion, models.EncryptionKeyVersion,
models.EncryptionVault, models.EncryptionVault,
@@ -238,6 +276,9 @@ manifest = ModuleManifest(
models.ProtectionMigration, models.ProtectionMigration,
models.RecoveryCeremony, models.RecoveryCeremony,
models.RecoveryApproval, models.RecoveryApproval,
models.EncryptionLocalKeyMaterial,
models.EncryptionLocalWrappedContentKey,
models.EncryptionLocalProviderOperation,
label=MODULE_NAME, label=MODULE_NAME,
), ),
), ),
@@ -255,8 +296,9 @@ manifest = ModuleManifest(
"implicitly grants cryptographic keys. High-risk lifecycle " "implicitly grants cryptographic keys. High-risk lifecycle "
"actions require recent Identity Trust assurance. Disabling is " "actions require recent Identity Trust assurance. Disabling is "
"blocked until each envelope is migrated, decrypted, explicitly " "blocked until each envelope is migrated, decrypted, explicitly "
"exported, or cryptographically destroyed. No bundled provider " "exported, or cryptographically destroyed. The bundled local "
"or E2EE claim is implied by enabling this module." "AES-GCM provider is server-readable and requires the deployment "
"master key; it does not imply end-to-end encryption."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -285,7 +327,7 @@ manifest = ModuleManifest(
documentation_ref="docs/ENCRYPTION_BOUNDARY.md", documentation_ref="docs/ENCRYPTION_BOUNDARY.md",
test_ref="tests/test_encryption.py", test_ref="tests/test_encryption.py",
known_limits=( 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.", "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=( owned_concepts=(
@@ -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")
+559 -1
View File
@@ -5,12 +5,19 @@ from collections.abc import Mapping
from datetime import datetime, timezone from datetime import datetime, timezone
import hashlib import hashlib
import json import json
from types import SimpleNamespace
import uuid
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.encryption import ( from govoplan_core.core.encryption import (
ContentCipherDecryptRequest,
ContentCipherEncryptRequest,
ContentKeyRewrapRequest,
ContentProtectionRequest,
ContentProtectionEnvelope, ContentProtectionEnvelope,
ContentUnprotectionRequest,
DisablePreflightReport, DisablePreflightReport,
KeyLifecycleRequest, KeyLifecycleRequest,
KeyMaterialDescriptor, KeyMaterialDescriptor,
@@ -22,9 +29,11 @@ from govoplan_core.core.encryption import (
ProtectionMigrationRequest, ProtectionMigrationRequest,
ProtectionMigrationResult, ProtectionMigrationResult,
ProtectionRegistrationRequest, ProtectionRegistrationRequest,
ProtectedContent,
RecoveryApprovalRequest, RecoveryApprovalRequest,
RecoveryRef, RecoveryRef,
RecoveryRequest, RecoveryRequest,
content_cipher_provider,
key_material_provider, key_material_provider,
) )
from govoplan_core.core.identity_trust import ( from govoplan_core.core.identity_trust import (
@@ -331,6 +340,496 @@ class SqlEncryptionService:
_require_tenant(principal, request.envelope.tenant_id) _require_tenant(principal, request.envelope.tenant_id)
return self._register_envelope(db, principal, request=request) 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( def get_envelope(
self, self,
session: object, session: object,
@@ -457,13 +956,21 @@ class SqlEncryptionService:
clean_evidence = tuple( clean_evidence = tuple(
value.strip() for value in evidence_refs if value.strip() 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 ( if (
item.state != normalized_state item.state != normalized_state
or tuple(item.evidence_refs) != clean_evidence or tuple(item.evidence_refs) != clean_evidence
): ):
raise EncryptionError("The migration already has another outcome.") raise EncryptionError("The migration already has another outcome.")
return self._migration_ref(db, item) 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: if normalized_state == "succeeded" and not clean_evidence:
raise EncryptionError( raise EncryptionError(
"A successful migration requires evidence references." "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: def _expected_revision(item: object, expected: int) -> None:
actual = int(getattr(item, "revision", 0)) actual = int(getattr(item, "revision", 0))
if actual != expected: if actual != expected:
+471
View File
@@ -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()
+7
View File
@@ -3,12 +3,16 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.encryption import ( from govoplan_core.core.encryption import (
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_VAULT, CAPABILITY_ENCRYPTION_KEY_VAULT,
CAPABILITY_ENCRYPTION_RECOVERY, CAPABILITY_ENCRYPTION_RECOVERY,
) )
from govoplan_encryption.backend.manifest import get_manifest from govoplan_encryption.backend.manifest import get_manifest
from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID
class EncryptionManifestTests(unittest.TestCase): class EncryptionManifestTests(unittest.TestCase):
@@ -21,8 +25,11 @@ class EncryptionManifestTests(unittest.TestCase):
{ {
CAPABILITY_ENCRYPTION_KEY_VAULT, CAPABILITY_ENCRYPTION_KEY_VAULT,
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
CAPABILITY_ENCRYPTION_RECOVERY, CAPABILITY_ENCRYPTION_RECOVERY,
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, 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), set(manifest.capability_factories),
) )
+4 -1
View File
@@ -32,11 +32,14 @@ class EncryptionMigrationTests(unittest.TestCase):
"encryption_protection_migrations", "encryption_protection_migrations",
"encryption_recovery_ceremonies", "encryption_recovery_ceremonies",
"encryption_recovery_approvals", "encryption_recovery_approvals",
"encryption_local_key_material",
"encryption_local_wrapped_content_keys",
"encryption_local_provider_operations",
}.issubset(tables) }.issubset(tables)
) )
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"d4a6b8c0e2f3", "e5b7c9d1f3a4",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
finally: finally: