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
@@ -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",
@@ -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 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=(
@@ -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
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: