feat: add governed local content encryption provider
This commit is contained in:
@@ -20,16 +20,25 @@ recovery ceremonies, and disable/uninstall assurance.
|
||||
state transitions;
|
||||
- recent high-assurance, distinct-custodian quorum recovery authorization;
|
||||
- typed APIs, audit-safe events, Alembic migration, and uninstall blocking;
|
||||
- Files and Postbox boundary fixtures.
|
||||
- a bundled `local_aesgcm` server-envelope provider using AES-256-GCM and
|
||||
SQL-persisted wrapped vault/content keys;
|
||||
- owner-facing content protect/open/rewrap and two-phase re-encryption
|
||||
operations that never export key material;
|
||||
- Files and Postbox encrypted-content adapters and fail-closed fixtures.
|
||||
|
||||
## Deliberately Not Bundled
|
||||
## Provider Boundary
|
||||
|
||||
The module contains no cipher implementation, raw key store, development master
|
||||
key, KMS/HSM driver, client E2EE protocol, or provider credentials. A deployment
|
||||
must install a separately reviewed provider capability. Until a concrete profile
|
||||
passes conformance, backup/restore, key-loss, interoperability, and security
|
||||
review, GovOPlaN must not claim that content is encrypted or end-to-end
|
||||
encrypted.
|
||||
The bundled provider derives a deployment wrapping key from `MASTER_KEY_B64` and
|
||||
keeps vault and per-content keys wrapped in shared SQL state. It provides real
|
||||
authenticated encryption at rest, but the application server can decrypt and
|
||||
therefore this is not E2EE, an HSM/KMS assurance claim, or independent
|
||||
certification. Production backup and restore must preserve the database,
|
||||
ciphertext storage, and exact deployment key as separate governed assets.
|
||||
|
||||
No client E2EE protocol, tenant KMS/HSM driver, provider credentials, or formal
|
||||
certification profile is bundled. Those remain provider-specific work and must
|
||||
pass conformance, backup/restore, key-loss, interoperability, and security review
|
||||
before a stronger product claim is made.
|
||||
|
||||
Feature modules continue to own content, authorization, retention, and resource
|
||||
ownership. Access approval, resource ownership, Identity Trust, and key custody
|
||||
|
||||
@@ -5,11 +5,16 @@
|
||||
`govoplan-encryption` is the optional governance and lifecycle boundary for
|
||||
cryptographic protection. It persists opaque provider references, versioned
|
||||
content-protection envelopes, idempotent operation state, recovery decisions,
|
||||
and disable-preflight evidence. It does not ship a cipher, a raw key store, a
|
||||
KMS/HSM driver, or a client E2EE protocol.
|
||||
and disable-preflight evidence. It ships a bounded `local_aesgcm` server-envelope
|
||||
provider. The provider uses AES-256-GCM, wraps generated vault keys under a key
|
||||
derived from deployment `MASTER_KEY_B64`, wraps one generated content key per
|
||||
envelope, and persists only wrapped keys, nonces, keyed commitments, ciphertext
|
||||
digests, and opaque references
|
||||
in shared SQL state. It does not ship a KMS/HSM driver or client E2EE protocol.
|
||||
|
||||
Enabling the module is therefore not an encryption or E2EE claim. A deployment
|
||||
may make such a claim only for a named protection profile whose concrete
|
||||
Selecting the bundled profile is an authenticated encryption-at-rest claim for
|
||||
the covered payload, not an E2EE, endpoint-security, HSM/KMS, or legal
|
||||
certification claim. Stronger claims require a named protection profile whose
|
||||
provider, clients, algorithms, backup procedure, and failure tests have passed
|
||||
the profile's conformance and security review.
|
||||
|
||||
@@ -72,6 +77,31 @@ encryption hides this metadata.
|
||||
|
||||
## Deployment Profiles
|
||||
|
||||
### Bundled local AES-GCM reference provider
|
||||
|
||||
`local_aesgcm` is a concrete server-side envelope provider intended for bounded
|
||||
deployments and integration verification. A per-deployment wrapping key is
|
||||
derived with HKDF-SHA-256 from `MASTER_KEY_B64`; the master key itself is never
|
||||
stored in the database. Vault keys and content keys are generated with the
|
||||
operating-system CSPRNG and wrapped with AES-256-GCM. Every content ciphertext
|
||||
authenticates a canonical tenant, owner, resource, profile, provider, vault,
|
||||
key-version, and algorithm context.
|
||||
|
||||
The provider stores no unkeyed plaintext digest. Its internal plaintext
|
||||
commitment is keyed by deployment custody and bound to the authenticated
|
||||
resource context, preventing an offline database reader from enumerating
|
||||
low-entropy values such as ballot choices.
|
||||
|
||||
The provider supports idempotent provision, rotation, revocation, destruction
|
||||
scheduling, content encryption/decryption, and same-provider key rewrap. A
|
||||
revoked vault key cannot protect new content but can open existing content until
|
||||
destruction. Due destruction clears wrapped vault material and causes reads to
|
||||
fail closed. Payload re-encryption is an explicit two-phase owner-coordinated
|
||||
workflow: Encryption decrypts and prepares an idempotent target ciphertext and
|
||||
exact envelope, while the owner must durably replace its payload before it
|
||||
records the migration as successful. This also permits cross-provider or
|
||||
algorithm migration without giving Encryption ownership of the payload store.
|
||||
|
||||
### Server-side envelope encryption
|
||||
|
||||
The application or a deployment KMS/HSM can unwrap data keys after normal
|
||||
@@ -143,9 +173,15 @@ operation as `prepared`, `succeeded`, or `outcome_unknown`. If a provider call
|
||||
raises or a process dies around the external side effect, absence of an error is
|
||||
never treated as success. Reconciliation repeats the exact idempotent provider
|
||||
request and records only bounded error class codes.
|
||||
An `outcome_unknown` content migration may subsequently be resolved to success
|
||||
or rejection; it is not an immutable terminal state.
|
||||
|
||||
Content migrations are explicit records. Rewrap and re-encryption succeed only
|
||||
when an exact target envelope references the authorized source and migration.
|
||||
Re-encryption preparation leaves the source in `migrating` and does not register
|
||||
the active target envelope. Repeating it with the same migration and target
|
||||
reference returns identical authenticated ciphertext. Only the owner's
|
||||
evidence-backed outcome call activates the target and retires the source.
|
||||
Decrypt, export, and destruction require evidence references. Until an outcome
|
||||
is known, the source remains `migration_pending`; no implicit plaintext fallback
|
||||
is allowed.
|
||||
@@ -206,7 +242,11 @@ They resolve Encryption through optional capabilities and must fail closed when
|
||||
an encrypted record is encountered without the capability. Unencrypted module
|
||||
combinations continue to operate without Encryption.
|
||||
|
||||
Focused fixtures currently validate Files- and Postbox-owned envelope records.
|
||||
Concrete feature adapters and production provider conformance remain tracked
|
||||
work; direct imports of Encryption database models from feature modules are not
|
||||
allowed.
|
||||
Focused fixtures validate Files and Postbox owner adapters: plaintext operation
|
||||
continues without Encryption, configured encrypted writes store ciphertext and
|
||||
an envelope link, encrypted reads fail closed when the capability is absent,
|
||||
and feature modules do not import Encryption models or providers. Files keeps
|
||||
plaintext semantic checksums separate from stored-ciphertext integrity and uses
|
||||
the protection profile in its deduplication identity. Postbox encrypts message
|
||||
bodies while subjects, routing, participant, and attachment-reference metadata
|
||||
remain observable.
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.14"]
|
||||
dependencies = ["cryptography>=44", "govoplan-core>=0.1.14"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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=(
|
||||
|
||||
+154
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -3,12 +3,16 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX,
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
)
|
||||
from govoplan_encryption.backend.manifest import get_manifest
|
||||
from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID
|
||||
|
||||
|
||||
class EncryptionManifestTests(unittest.TestCase):
|
||||
@@ -21,8 +25,11 @@ class EncryptionManifestTests(unittest.TestCase):
|
||||
{
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
|
||||
f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
|
||||
},
|
||||
set(manifest.capability_factories),
|
||||
)
|
||||
|
||||
@@ -32,11 +32,14 @@ class EncryptionMigrationTests(unittest.TestCase):
|
||||
"encryption_protection_migrations",
|
||||
"encryption_recovery_ceremonies",
|
||||
"encryption_recovery_approvals",
|
||||
"encryption_local_key_material",
|
||||
"encryption_local_wrapped_content_keys",
|
||||
"encryption_local_provider_operations",
|
||||
}.issubset(tables)
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"d4a6b8c0e2f3",
|
||||
"e5b7c9d1f3a4",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user