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