Files
govoplan-encryption/src/govoplan_encryption/backend/service.py
T

1470 lines
51 KiB
Python

from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from datetime import datetime, timezone
import hashlib
import json
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.core.encryption import (
ContentProtectionEnvelope,
DisablePreflightReport,
KeyLifecycleRequest,
KeyMaterialDescriptor,
KeyMaterialProvisionRequest,
KeyRotationRequest,
KeyVaultCreateRequest,
KeyVaultRef,
KeyVersionRef,
ProtectionMigrationRequest,
ProtectionMigrationResult,
ProtectionRegistrationRequest,
RecoveryApprovalRequest,
RecoveryRef,
RecoveryRequest,
key_material_provider,
)
from govoplan_core.core.identity_trust import (
AssuranceCheckRequest,
identity_trust_assurance,
)
from govoplan_core.db.base import utcnow
from govoplan_encryption.backend.db.models import (
ContentProtectionRecord,
EncryptionKeyOperation,
EncryptionKeyVersion,
EncryptionVault,
ProtectionMigration,
RecoveryApproval,
RecoveryCeremony,
)
TERMINAL_PROTECTION_STATES = frozenset(
{"migrated", "decrypted", "exported", "destroyed"}
)
class EncryptionError(ValueError):
pass
class SqlEncryptionService:
"""Provider-neutral encryption metadata and recovery orchestration.
Concrete providers receive only opaque references and idempotent operation
requests. This service never accepts or returns plaintext key material.
"""
def __init__(self, registry: object | None = None) -> None:
self.registry = registry
def create_vault(
self,
session: object,
principal: object,
*,
request: KeyVaultCreateRequest,
) -> KeyVaultRef:
db = _session(session)
_require_tenant(principal, request.tenant_id)
provider = self._provider(request.provider_id)
payload = _create_payload(request)
digest = _digest(payload)
replay = self._operation_replay(
db,
tenant_id=request.tenant_id,
idempotency_key=request.idempotency_key,
digest=digest,
)
if replay is not None:
return self._vault_ref(
self._vault(
db,
tenant_id=request.tenant_id,
vault_id=replay.vault_id,
),
db,
)
if db.scalar(
select(EncryptionVault.id).where(
EncryptionVault.tenant_id == request.tenant_id,
EncryptionVault.vault_id == request.vault_id,
)
):
raise EncryptionError("The vault id already exists.")
actor_id = _account_id(principal)
vault = EncryptionVault(
tenant_id=request.tenant_id,
vault_id=request.vault_id,
name=request.name.strip(),
provider_id=request.provider_id,
purpose=request.purpose,
profile_kind=request.profile_kind,
scope_type=request.scope_type,
scope_id=request.scope_id,
policy_ref=request.policy_ref,
recovery_quorum=request.recovery_quorum,
state="provisioning",
revision=1,
current_key_version=None,
create_idempotency_key=request.idempotency_key,
create_request_digest=digest,
provenance={"contract_version": request.contract_version},
created_by=actor_id,
updated_by=actor_id,
)
key = EncryptionKeyVersion(
tenant_id=request.tenant_id,
vault_id=request.vault_id,
version=1,
provider_id=request.provider_id,
provider_key_ref=None,
algorithm_suite=request.algorithm_suite,
state="unavailable",
)
operation = self._new_operation(
request.tenant_id,
request.vault_id,
1,
"provision",
request.provider_id,
request.idempotency_key,
digest,
payload,
actor_id,
policy_decision_ref=request.policy_ref,
)
db.add_all((vault, key, operation))
db.flush()
self._execute_provision(
db,
principal,
provider=provider,
vault=vault,
key=key,
operation=operation,
)
return self._vault_ref(vault, db)
def rotate_key(
self,
session: object,
principal: object,
*,
request: KeyRotationRequest,
) -> KeyVaultRef:
db = _session(session)
_require_tenant(principal, request.tenant_id)
vault = self._vault(
db,
tenant_id=request.tenant_id,
vault_id=request.vault_id,
lock=True,
)
if vault.current_key_version is None:
raise EncryptionError("The vault has no active key to rotate.")
current_key = self._key(db, vault, vault.current_key_version)
algorithm_suite = request.algorithm_suite or current_key.algorithm_suite
payload = _rotation_payload(request, vault, algorithm_suite)
digest = _digest(payload)
replay = self._operation_replay(
db,
tenant_id=request.tenant_id,
idempotency_key=request.idempotency_key,
digest=digest,
)
if replay is not None:
return self._vault_ref(vault, db)
_expected_revision(vault, request.expected_revision)
self._verify_assurance(
db,
principal,
tenant_id=request.tenant_id,
evidence_ref=request.assurance_evidence_ref,
purpose="encryption.key.rotate",
)
provider = self._provider(vault.provider_id)
version = vault.current_key_version + 1
actor_id = _account_id(principal)
key = EncryptionKeyVersion(
tenant_id=request.tenant_id,
vault_id=request.vault_id,
version=version,
provider_id=vault.provider_id,
provider_key_ref=None,
algorithm_suite=algorithm_suite,
state="unavailable",
)
operation = self._new_operation(
request.tenant_id,
request.vault_id,
version,
"rotate",
vault.provider_id,
request.idempotency_key,
digest,
payload,
actor_id,
policy_decision_ref=request.policy_decision_ref,
assurance_evidence_ref=request.assurance_evidence_ref,
)
vault.state = "rotation_pending"
vault.revision += 1
vault.updated_by = actor_id
db.add_all((key, operation))
db.flush()
self._execute_provision(
db,
principal,
provider=provider,
vault=vault,
key=key,
operation=operation,
)
return self._vault_ref(vault, db)
def revoke_key(
self,
session: object,
principal: object,
*,
request: KeyLifecycleRequest,
) -> KeyVaultRef:
return self._change_key_lifecycle(
session,
principal,
request=request,
operation_name="revoke",
)
def schedule_destruction(
self,
session: object,
principal: object,
*,
request: KeyLifecycleRequest,
) -> KeyVaultRef:
if request.effective_at is None:
raise EncryptionError("A destruction schedule requires an effective time.")
return self._change_key_lifecycle(
session,
principal,
request=request,
operation_name="destroy",
)
def get_vault(
self,
session: object,
*,
tenant_id: str,
vault_id: str,
) -> KeyVaultRef | None:
db = _session(session)
vault = db.scalar(
select(EncryptionVault).where(
EncryptionVault.tenant_id == tenant_id,
EncryptionVault.vault_id == vault_id,
)
)
return self._vault_ref(vault, db) if vault is not None else None
def reconcile_vault(
self,
session: object,
principal: object,
*,
tenant_id: str,
vault_id: str,
) -> KeyVaultRef:
db = _session(session)
_require_tenant(principal, tenant_id)
vault = self._vault(db, tenant_id=tenant_id, vault_id=vault_id, lock=True)
operation = db.scalar(
select(EncryptionKeyOperation)
.where(
EncryptionKeyOperation.tenant_id == tenant_id,
EncryptionKeyOperation.vault_id == vault_id,
EncryptionKeyOperation.state == "outcome_unknown",
)
.order_by(EncryptionKeyOperation.created_at.desc())
.with_for_update()
)
if operation is None:
return self._vault_ref(vault, db)
provider = self._provider(operation.provider_id)
key = self._key(db, vault, operation.key_version)
if operation.operation in {"provision", "rotate"}:
self._execute_provision(
db,
principal,
provider=provider,
vault=vault,
key=key,
operation=operation,
reconciliation=True,
)
else:
self._execute_lifecycle(
db,
principal,
provider=provider,
vault=vault,
key=key,
operation=operation,
reconciliation=True,
)
return self._vault_ref(vault, db)
def register_envelope(
self,
session: object,
principal: object,
*,
request: ProtectionRegistrationRequest,
) -> ContentProtectionEnvelope:
db = _session(session)
_require_tenant(principal, request.envelope.tenant_id)
return self._register_envelope(db, principal, request=request)
def get_envelope(
self,
session: object,
principal: object,
*,
tenant_id: str,
envelope_id: str,
) -> ContentProtectionEnvelope | None:
db = _session(session)
_require_tenant(principal, tenant_id)
item = db.scalar(
select(ContentProtectionRecord).where(
ContentProtectionRecord.tenant_id == tenant_id,
ContentProtectionRecord.envelope_id == envelope_id,
)
)
return _envelope_ref(item) if item is not None else None
def request_migration(
self,
session: object,
principal: object,
*,
request: ProtectionMigrationRequest,
) -> ProtectionMigrationResult:
db = _session(session)
_require_tenant(principal, request.tenant_id)
source = self._envelope(
db,
tenant_id=request.tenant_id,
envelope_id=request.envelope_id,
lock=True,
)
payload = _migration_payload(request)
digest = _digest(payload)
replay = db.scalar(
select(ProtectionMigration).where(
ProtectionMigration.tenant_id == request.tenant_id,
ProtectionMigration.idempotency_key == request.idempotency_key,
)
)
if replay is not None:
if replay.request_digest != digest:
raise EncryptionError(
"The migration idempotency key was reused for another request."
)
return self._migration_ref(db, replay)
if source.state != "active":
raise EncryptionError("Only an active envelope can enter migration.")
target_vault = self._vault(
db,
tenant_id=request.tenant_id,
vault_id=request.target_vault_id,
)
target_key = self._key(db, target_vault, request.target_key_version)
if (
target_vault.provider_id != request.target_provider_id
or target_key.algorithm_suite != request.target_algorithm_suite
or target_key.state != "active"
):
raise EncryptionError(
"The target vault, key version, provider, and algorithm do not match."
)
self._verify_assurance(
db,
principal,
tenant_id=request.tenant_id,
evidence_ref=request.assurance_evidence_ref,
purpose=f"encryption.content.{request.mode}",
)
item = ProtectionMigration(
tenant_id=request.tenant_id,
source_envelope_id=request.envelope_id,
target_provider_id=request.target_provider_id,
target_vault_id=request.target_vault_id,
target_key_version=request.target_key_version,
target_algorithm_suite=request.target_algorithm_suite,
mode=request.mode,
state="requested",
policy_decision_ref=request.policy_decision_ref,
assurance_evidence_ref=request.assurance_evidence_ref,
idempotency_key=request.idempotency_key,
request_digest=digest,
evidence_refs=[],
provenance={"mixed_state_allowed": False},
requested_by=_account_id(principal),
)
db.add(item)
db.flush()
source.state = "migration_pending"
source.migration_id = item.id
db.flush()
return self._migration_ref(db, item)
def record_migration_outcome(
self,
session: object,
principal: object,
*,
migration_id: str,
state: str,
evidence_refs: tuple[str, ...],
target_envelope: ContentProtectionEnvelope | None = None,
error_code: str | None = None,
) -> ProtectionMigrationResult:
db = _session(session)
item = db.scalar(
select(ProtectionMigration)
.where(ProtectionMigration.id == migration_id)
.with_for_update()
)
if item is None:
raise EncryptionError("Protection migration not found.")
_require_tenant(principal, item.tenant_id)
source = self._envelope(
db,
tenant_id=item.tenant_id,
envelope_id=item.source_envelope_id,
lock=True,
)
normalized_state = state.strip().lower()
if normalized_state not in {"succeeded", "rejected", "outcome_unknown"}:
raise EncryptionError("Unsupported migration outcome.")
clean_evidence = tuple(
value.strip() for value in evidence_refs if value.strip()
)
if item.state in {"succeeded", "rejected", "outcome_unknown"}:
if (
item.state != normalized_state
or tuple(item.evidence_refs) != clean_evidence
):
raise EncryptionError("The migration already has another outcome.")
return self._migration_ref(db, item)
if normalized_state == "succeeded" and not clean_evidence:
raise EncryptionError(
"A successful migration requires evidence references."
)
if normalized_state == "succeeded":
if item.mode in {"rewrap", "reencrypt"}:
if target_envelope is None:
raise EncryptionError(
"Rewrap and re-encryption require a target envelope."
)
self._validate_migration_target(item, source, target_envelope)
target = self._register_envelope(
db,
principal,
request=ProtectionRegistrationRequest(
envelope=target_envelope,
idempotency_key=f"migration:{item.id}:target",
policy_decision_ref=item.policy_decision_ref,
),
)
item.target_envelope_id = target.envelope_id
source.state = "migrated"
elif item.mode == "decrypt":
source.state = "decrypted"
elif item.mode == "export":
source.state = "exported"
elif item.mode == "destroy":
source.state = "destroyed"
item.completed_at = _as_utc(utcnow())
elif normalized_state == "rejected":
source.state = "active"
source.migration_id = None
item.completed_at = _as_utc(utcnow())
else:
source.state = "migration_pending"
item.state = normalized_state
item.evidence_refs = list(clean_evidence)
item.error_code = _safe_error_code(error_code)
item.provenance = {
**dict(item.provenance),
"outcome_recorded_by": _account_id(principal),
"cryptographic_material_present": False,
}
db.flush()
return self._migration_ref(db, item)
def reconcile_migration(
self,
session: object,
principal: object,
*,
migration_id: str,
) -> ProtectionMigrationResult:
db = _session(session)
item = db.get(ProtectionMigration, migration_id)
if item is None:
raise EncryptionError("Protection migration not found.")
_require_tenant(principal, item.tenant_id)
return self._migration_ref(db, item)
def request_recovery(
self,
session: object,
principal: object,
*,
request: RecoveryRequest,
) -> RecoveryRef:
db = _session(session)
_require_tenant(principal, request.tenant_id)
now = _as_utc(utcnow())
if _as_utc(request.expires_at) <= now:
raise EncryptionError("Recovery expiry must be in the future.")
vault = self._vault(
db,
tenant_id=request.tenant_id,
vault_id=request.vault_id,
)
payload = _recovery_payload(request)
digest = _digest(payload)
replay = db.scalar(
select(RecoveryCeremony).where(
RecoveryCeremony.tenant_id == request.tenant_id,
RecoveryCeremony.idempotency_key == request.idempotency_key,
)
)
if replay is not None:
if replay.request_digest != digest:
raise EncryptionError(
"The recovery idempotency key was reused for another request."
)
return self._recovery_ref(db, replay)
self._verify_assurance(
db,
principal,
tenant_id=request.tenant_id,
evidence_ref=request.assurance_evidence_ref,
purpose="encryption.recovery.request",
)
item = RecoveryCeremony(
tenant_id=request.tenant_id,
vault_id=request.vault_id,
state="pending",
requested_scope=request.requested_scope,
reason=request.reason,
quorum=vault.recovery_quorum,
revision=1,
policy_decision_ref=request.policy_decision_ref,
requester_assurance_ref=request.assurance_evidence_ref,
requester_account_id=_account_id(principal),
idempotency_key=request.idempotency_key,
request_digest=digest,
expires_at=_as_utc(request.expires_at),
provenance={
"key_material_released": False,
"resource_ownership_changed": False,
},
)
db.add(item)
db.flush()
return self._recovery_ref(db, item)
def decide_recovery(
self,
session: object,
principal: object,
*,
request: RecoveryApprovalRequest,
) -> RecoveryRef:
db = _session(session)
_require_tenant(principal, request.tenant_id)
ceremony = db.scalar(
select(RecoveryCeremony)
.where(
RecoveryCeremony.tenant_id == request.tenant_id,
RecoveryCeremony.id == request.recovery_id,
)
.with_for_update()
)
if ceremony is None:
raise EncryptionError("Recovery ceremony not found.")
self._refresh_recovery_state(ceremony)
if ceremony.state != "pending":
return self._recovery_ref(db, ceremony)
_expected_revision(ceremony, request.expected_revision)
actor_id = _account_id(principal)
if actor_id == ceremony.requester_account_id:
raise EncryptionError("The requester cannot approve their own recovery.")
payload = {
"tenant_id": request.tenant_id,
"recovery_id": request.recovery_id,
"decision": request.decision,
"reason": request.reason,
"assurance_evidence_ref": request.assurance_evidence_ref,
}
digest = _digest(payload)
replay = db.scalar(
select(RecoveryApproval).where(
RecoveryApproval.tenant_id == request.tenant_id,
RecoveryApproval.idempotency_key == request.idempotency_key,
)
)
if replay is not None:
if replay.request_digest != digest:
raise EncryptionError(
"The approval idempotency key was reused for another decision."
)
return self._recovery_ref(db, ceremony)
if db.scalar(
select(RecoveryApproval.id).where(
RecoveryApproval.tenant_id == request.tenant_id,
RecoveryApproval.recovery_id == request.recovery_id,
RecoveryApproval.approver_account_id == actor_id,
)
):
raise EncryptionError("Each custodian may decide a recovery only once.")
self._verify_assurance(
db,
principal,
tenant_id=request.tenant_id,
evidence_ref=request.assurance_evidence_ref,
purpose="encryption.recovery.approve",
)
db.add(
RecoveryApproval(
tenant_id=request.tenant_id,
recovery_id=request.recovery_id,
approver_account_id=actor_id,
decision=request.decision,
reason=request.reason,
assurance_evidence_ref=request.assurance_evidence_ref,
idempotency_key=request.idempotency_key,
request_digest=digest,
)
)
db.flush()
decisions = Counter(
db.scalars(
select(RecoveryApproval.decision).where(
RecoveryApproval.tenant_id == request.tenant_id,
RecoveryApproval.recovery_id == request.recovery_id,
)
)
)
if decisions["reject"]:
ceremony.state = "rejected"
elif decisions["approve"] >= ceremony.quorum:
ceremony.state = "approved"
ceremony.revision += 1
ceremony.provenance = {
**dict(ceremony.provenance),
"approved_by_distinct_accounts": decisions["approve"],
"key_material_released": False,
}
db.flush()
return self._recovery_ref(db, ceremony)
def assess_disable(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> DisablePreflightReport:
db = _session(session)
statement = select(
ContentProtectionRecord.state,
func.count(ContentProtectionRecord.id),
).group_by(ContentProtectionRecord.state)
if tenant_id is not None:
statement = statement.where(ContentProtectionRecord.tenant_id == tenant_id)
state_counts = {
str(state): int(count) for state, count in db.execute(statement).all()
}
unresolved = sum(
count
for state, count in state_counts.items()
if state not in TERMINAL_PROTECTION_STATES
)
blockers = select(
ContentProtectionRecord.tenant_id,
ContentProtectionRecord.envelope_id,
).where(ContentProtectionRecord.state.not_in(TERMINAL_PROTECTION_STATES))
if tenant_id is not None:
blockers = blockers.where(ContentProtectionRecord.tenant_id == tenant_id)
blockers = blockers.order_by(
ContentProtectionRecord.tenant_id,
ContentProtectionRecord.envelope_id,
).limit(max(1, min(limit, 500)))
refs = tuple(
f"{tenant}:{envelope}" for tenant, envelope in db.execute(blockers)
)
return DisablePreflightReport(
allowed=unresolved == 0,
protected_count=sum(state_counts.values()),
unresolved_count=unresolved,
state_counts=state_counts,
blocking_envelope_refs=refs,
required_actions=(
()
if unresolved == 0
else (
"Migrate, decrypt, explicitly export, or cryptographically destroy every unresolved envelope.",
"Reconcile all outcome-unknown operations before disabling Encryption.",
)
),
generated_at=_as_utc(utcnow()),
)
def _change_key_lifecycle(
self,
session: object,
principal: object,
*,
request: KeyLifecycleRequest,
operation_name: str,
) -> KeyVaultRef:
db = _session(session)
_require_tenant(principal, request.tenant_id)
vault = self._vault(
db,
tenant_id=request.tenant_id,
vault_id=request.vault_id,
lock=True,
)
payload = _lifecycle_payload(request, operation_name)
digest = _digest(payload)
replay = self._operation_replay(
db,
tenant_id=request.tenant_id,
idempotency_key=request.idempotency_key,
digest=digest,
)
if replay is not None:
return self._vault_ref(vault, db)
_expected_revision(vault, request.expected_revision)
key = self._key(db, vault, request.key_version)
if key.provider_key_ref is None:
raise EncryptionError("The key provider reference is unavailable.")
self._verify_assurance(
db,
principal,
tenant_id=request.tenant_id,
evidence_ref=request.assurance_evidence_ref,
purpose=f"encryption.key.{operation_name}",
)
operation = self._new_operation(
request.tenant_id,
request.vault_id,
request.key_version,
operation_name,
vault.provider_id,
request.idempotency_key,
digest,
payload,
_account_id(principal),
policy_decision_ref=request.policy_decision_ref,
assurance_evidence_ref=request.assurance_evidence_ref,
)
vault.state = f"{operation_name}_pending"
vault.revision += 1
vault.updated_by = _account_id(principal)
db.add(operation)
db.flush()
self._execute_lifecycle(
db,
principal,
provider=self._provider(vault.provider_id),
vault=vault,
key=key,
operation=operation,
)
return self._vault_ref(vault, db)
def _execute_provision(
self,
db: Session,
principal: object,
*,
provider: object,
vault: EncryptionVault,
key: EncryptionKeyVersion,
operation: EncryptionKeyOperation,
reconciliation: bool = False,
) -> None:
payload = operation.request_payload
request = KeyMaterialProvisionRequest(
tenant_id=operation.tenant_id,
vault_id=operation.vault_id,
key_version=operation.key_version,
algorithm_suite=str(payload["algorithm_suite"]),
purpose=str(payload["purpose"]),
idempotency_key=operation.idempotency_key,
provider_policy=dict(payload.get("provider_policy") or {}),
import_reference=(
str(payload["import_reference"])
if payload.get("import_reference") is not None
else None
),
)
try:
descriptor = provider.provision_key( # type: ignore[attr-defined]
db,
principal,
request=request,
)
self._apply_descriptor(vault, key, descriptor)
operation.state = "succeeded"
operation.error_code = None
operation.completed_at = _as_utc(utcnow())
vault.current_key_version = key.version
vault.state = "active"
if reconciliation:
vault.revision += 1
except Exception as exc:
operation.state = "outcome_unknown"
operation.error_code = _exception_code(exc)
vault.state = "outcome_unknown"
key.state = "unavailable"
db.flush()
def _execute_lifecycle(
self,
db: Session,
principal: object,
*,
provider: object,
vault: EncryptionVault,
key: EncryptionKeyVersion,
operation: EncryptionKeyOperation,
reconciliation: bool = False,
) -> None:
if key.provider_key_ref is None:
raise EncryptionError("The key provider reference is unavailable.")
try:
if operation.operation == "revoke":
descriptor = provider.revoke_key( # type: ignore[attr-defined]
db,
principal,
provider_key_ref=key.provider_key_ref,
reason=str(operation.request_payload["reason"]),
idempotency_key=operation.idempotency_key,
)
else:
effective_at = datetime.fromisoformat(
str(operation.request_payload["effective_at"])
)
descriptor = provider.schedule_key_destruction( # type: ignore[attr-defined]
db,
principal,
provider_key_ref=key.provider_key_ref,
effective_at=effective_at,
idempotency_key=operation.idempotency_key,
)
self._apply_descriptor(vault, key, descriptor)
operation.state = "succeeded"
operation.error_code = None
operation.completed_at = _as_utc(utcnow())
vault.state = "active"
if key.version == vault.current_key_version and key.state in {
"revoked",
"destroyed",
}:
vault.state = "unavailable"
if reconciliation:
vault.revision += 1
except Exception as exc:
operation.state = "outcome_unknown"
operation.error_code = _exception_code(exc)
vault.state = "outcome_unknown"
db.flush()
@staticmethod
def _apply_descriptor(
vault: EncryptionVault,
key: EncryptionKeyVersion,
descriptor: KeyMaterialDescriptor,
) -> None:
if descriptor.provider_id != vault.provider_id:
raise EncryptionError("The provider returned a key for another provider.")
if descriptor.algorithm_suite != key.algorithm_suite:
raise EncryptionError("The provider returned another algorithm suite.")
key.provider_key_ref = descriptor.provider_key_ref
key.state = descriptor.state
key.public_key_ref = descriptor.public_key_ref
key.imported = descriptor.imported
key.exportable = descriptor.exportable
key.provider_version = descriptor.provider_version
key.provider_provenance = dict(descriptor.provenance)
if descriptor.state == "active" and key.activated_at is None:
key.activated_at = _as_utc(descriptor.created_at)
if descriptor.state == "revoked":
key.revoked_at = _as_utc(utcnow())
if descriptor.state == "destruction_scheduled":
key.destruction_scheduled_at = _as_utc(utcnow())
if descriptor.state == "destroyed":
key.destroyed_at = _as_utc(utcnow())
def _register_envelope(
self,
db: Session,
principal: object,
*,
request: ProtectionRegistrationRequest,
) -> ContentProtectionEnvelope:
envelope = request.envelope
digest = _digest(_envelope_payload(envelope, request.policy_decision_ref))
replay = db.scalar(
select(ContentProtectionRecord).where(
ContentProtectionRecord.tenant_id == envelope.tenant_id,
ContentProtectionRecord.idempotency_key == request.idempotency_key,
)
)
if replay is not None:
if replay.request_digest != digest:
raise EncryptionError(
"The envelope idempotency key was reused for another envelope."
)
return _envelope_ref(replay)
if db.scalar(
select(ContentProtectionRecord.id).where(
ContentProtectionRecord.tenant_id == envelope.tenant_id,
ContentProtectionRecord.envelope_id == envelope.envelope_id,
)
):
raise EncryptionError("The protection envelope id already exists.")
vault = self._vault(
db,
tenant_id=envelope.tenant_id,
vault_id=envelope.vault_id,
)
key = self._key(db, vault, envelope.key_version)
if (
vault.provider_id != envelope.provider_id
or vault.profile_kind != envelope.profile_kind
or key.algorithm_suite != envelope.algorithm_suite
or key.state != "active"
):
raise EncryptionError(
"The envelope does not match an active vault key and protection profile."
)
item = ContentProtectionRecord(
envelope_id=envelope.envelope_id,
tenant_id=envelope.tenant_id,
owner_module=envelope.owner_module,
resource_type=envelope.resource_type,
resource_id=envelope.resource_id,
profile_kind=envelope.profile_kind,
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,
ciphertext_ref=envelope.ciphertext_ref,
ciphertext_digest=envelope.ciphertext_digest,
authenticated_context_digest=envelope.authenticated_context_digest,
wrapped_key_refs=list(envelope.wrapped_key_refs),
state=envelope.state,
source_envelope_id=envelope.source_envelope_id,
migration_id=envelope.migration_id,
envelope_metadata=dict(envelope.metadata),
idempotency_key=request.idempotency_key,
request_digest=digest,
policy_decision_ref=request.policy_decision_ref,
registered_by=_account_id(principal),
)
db.add(item)
db.flush()
return _envelope_ref(item)
@staticmethod
def _validate_migration_target(
item: ProtectionMigration,
source: ContentProtectionRecord,
target: ContentProtectionEnvelope,
) -> None:
if (
target.tenant_id != item.tenant_id
or target.source_envelope_id != source.envelope_id
or target.migration_id != item.id
or target.owner_module != source.owner_module
or target.resource_type != source.resource_type
or target.resource_id != source.resource_id
or target.provider_id != item.target_provider_id
or target.vault_id != item.target_vault_id
or target.key_version != item.target_key_version
or target.algorithm_suite != item.target_algorithm_suite
or target.state != "active"
):
raise EncryptionError(
"The target envelope does not exactly match the authorized migration."
)
def _migration_ref(
self,
db: Session,
item: ProtectionMigration,
) -> ProtectionMigrationResult:
target = None
if item.target_envelope_id:
target_item = db.scalar(
select(ContentProtectionRecord).where(
ContentProtectionRecord.tenant_id == item.tenant_id,
ContentProtectionRecord.envelope_id == item.target_envelope_id,
)
)
target = _envelope_ref(target_item) if target_item is not None else None
return ProtectionMigrationResult(
migration_id=item.id,
state=item.state, # type: ignore[arg-type]
source_envelope_id=item.source_envelope_id,
target_envelope=target,
error=item.error_code,
evidence_refs=tuple(item.evidence_refs),
provenance={
**dict(item.provenance),
"mode": item.mode,
"policy_decision_ref": item.policy_decision_ref,
},
)
def _recovery_ref(self, db: Session, item: RecoveryCeremony) -> RecoveryRef:
self._refresh_recovery_state(item)
decisions = Counter(
db.scalars(
select(RecoveryApproval.decision).where(
RecoveryApproval.tenant_id == item.tenant_id,
RecoveryApproval.recovery_id == item.id,
)
)
)
return RecoveryRef(
tenant_id=item.tenant_id,
recovery_id=item.id,
vault_id=item.vault_id,
state=item.state, # type: ignore[arg-type]
requested_scope=item.requested_scope,
quorum=item.quorum,
approvals=decisions["approve"],
rejections=decisions["reject"],
revision=item.revision,
expires_at=item.expires_at,
policy_decision_ref=item.policy_decision_ref,
execution_ref=item.execution_ref,
provenance=dict(item.provenance),
)
@staticmethod
def _refresh_recovery_state(item: RecoveryCeremony) -> None:
if item.state == "pending" and _as_utc(item.expires_at) <= _as_utc(utcnow()):
item.state = "expired"
item.revision += 1
def _verify_assurance(
self,
db: Session,
principal: object,
*,
tenant_id: str,
evidence_ref: str,
purpose: str,
) -> None:
capability = identity_trust_assurance(self.registry)
if capability is None:
raise EncryptionError(
"Recent high-assurance evidence is required, but Identity Trust is unavailable."
)
decision = capability.verify_assurance(
db,
principal,
request=AssuranceCheckRequest(
tenant_id=tenant_id,
account_id=_account_id(principal),
purpose=purpose,
minimum_level="high",
evidence_ref=evidence_ref,
evaluated_at=_as_utc(utcnow()),
maximum_age_seconds=300,
),
)
if not decision.allowed:
raise EncryptionError(
f"High-assurance verification failed: {decision.reason}"
)
def _provider(self, provider_id: str) -> object:
provider = key_material_provider(self.registry, provider_id)
if provider is None:
raise EncryptionError(
f"Encryption key-material provider {provider_id!r} is unavailable."
)
return provider
@staticmethod
def _new_operation(
tenant_id: str,
vault_id: str,
key_version: int,
operation: str,
provider_id: str,
idempotency_key: str,
request_digest: str,
request_payload: Mapping[str, object],
requested_by: str,
*,
policy_decision_ref: str | None = None,
assurance_evidence_ref: str | None = None,
) -> EncryptionKeyOperation:
return EncryptionKeyOperation(
tenant_id=tenant_id,
vault_id=vault_id,
key_version=key_version,
operation=operation,
provider_id=provider_id,
state="prepared",
idempotency_key=idempotency_key,
request_digest=request_digest,
request_payload=dict(request_payload),
policy_decision_ref=policy_decision_ref,
assurance_evidence_ref=assurance_evidence_ref,
requested_by=requested_by,
)
@staticmethod
def _operation_replay(
db: Session,
*,
tenant_id: str,
idempotency_key: str,
digest: str,
) -> EncryptionKeyOperation | None:
item = db.scalar(
select(EncryptionKeyOperation).where(
EncryptionKeyOperation.tenant_id == tenant_id,
EncryptionKeyOperation.idempotency_key == idempotency_key,
)
)
if item is not None and item.request_digest != digest:
raise EncryptionError(
"The key-operation idempotency key was reused for another request."
)
return item
@staticmethod
def _vault(
db: Session,
*,
tenant_id: str,
vault_id: str,
lock: bool = False,
) -> EncryptionVault:
statement = select(EncryptionVault).where(
EncryptionVault.tenant_id == tenant_id,
EncryptionVault.vault_id == vault_id,
)
if lock:
statement = statement.with_for_update()
item = db.scalar(statement)
if item is None:
raise EncryptionError("Encryption vault not found.")
return item
@staticmethod
def _key(
db: Session,
vault: EncryptionVault,
version: int,
) -> EncryptionKeyVersion:
item = db.scalar(
select(EncryptionKeyVersion).where(
EncryptionKeyVersion.tenant_id == vault.tenant_id,
EncryptionKeyVersion.vault_id == vault.vault_id,
EncryptionKeyVersion.version == version,
)
)
if item is None:
raise EncryptionError("Encryption key version not found.")
return item
@staticmethod
def _envelope(
db: Session,
*,
tenant_id: str,
envelope_id: str,
lock: bool = False,
) -> ContentProtectionRecord:
statement = select(ContentProtectionRecord).where(
ContentProtectionRecord.tenant_id == tenant_id,
ContentProtectionRecord.envelope_id == envelope_id,
)
if lock:
statement = statement.with_for_update()
item = db.scalar(statement)
if item is None:
raise EncryptionError("Protection envelope not found.")
return item
def _vault_ref(self, item: EncryptionVault, db: Session) -> KeyVaultRef:
key = None
if item.current_key_version is not None:
key = _key_ref(self._key(db, item, item.current_key_version))
return KeyVaultRef(
tenant_id=item.tenant_id,
vault_id=item.vault_id,
name=item.name,
provider_id=item.provider_id,
purpose=item.purpose,
profile_kind=item.profile_kind, # type: ignore[arg-type]
scope_type=item.scope_type,
scope_id=item.scope_id,
policy_ref=item.policy_ref,
recovery_quorum=item.recovery_quorum,
state=item.state,
revision=item.revision,
current_key=key,
created_at=item.created_at,
updated_at=item.updated_at,
provenance=dict(item.provenance),
)
def _key_ref(item: EncryptionKeyVersion) -> KeyVersionRef:
if item.provider_key_ref is None:
raise EncryptionError("The current key has no provider reference.")
return KeyVersionRef(
tenant_id=item.tenant_id,
vault_id=item.vault_id,
version=item.version,
provider_id=item.provider_id,
provider_key_ref=item.provider_key_ref,
algorithm_suite=item.algorithm_suite,
state=item.state, # type: ignore[arg-type]
created_at=item.created_at,
activated_at=item.activated_at,
revoked_at=item.revoked_at,
destruction_scheduled_at=item.destruction_scheduled_at,
destroyed_at=item.destroyed_at,
public_key_ref=item.public_key_ref,
imported=item.imported,
exportable=item.exportable,
provenance={
**dict(item.provider_provenance),
"provider_version": item.provider_version,
},
)
def _envelope_ref(item: ContentProtectionRecord) -> ContentProtectionEnvelope:
return ContentProtectionEnvelope(
envelope_id=item.envelope_id,
tenant_id=item.tenant_id,
owner_module=item.owner_module,
resource_type=item.resource_type,
resource_id=item.resource_id,
profile_kind=item.profile_kind, # type: ignore[arg-type]
profile_id=item.profile_id,
provider_id=item.provider_id,
vault_id=item.vault_id,
key_version=item.key_version,
algorithm_suite=item.algorithm_suite,
ciphertext_ref=item.ciphertext_ref,
ciphertext_digest=item.ciphertext_digest,
authenticated_context_digest=item.authenticated_context_digest,
state=item.state, # type: ignore[arg-type]
created_at=item.created_at,
wrapped_key_refs=tuple(item.wrapped_key_refs),
source_envelope_id=item.source_envelope_id,
migration_id=item.migration_id,
metadata=dict(item.envelope_metadata),
)
def _create_payload(request: KeyVaultCreateRequest) -> dict[str, object]:
return {
"tenant_id": request.tenant_id,
"vault_id": request.vault_id,
"name": request.name,
"provider_id": request.provider_id,
"purpose": request.purpose,
"algorithm_suite": request.algorithm_suite,
"scope_type": request.scope_type,
"scope_id": request.scope_id,
"policy_ref": request.policy_ref,
"recovery_quorum": request.recovery_quorum,
"profile_kind": request.profile_kind,
"import_reference": request.import_reference,
"provider_policy": dict(request.provider_policy),
}
def _rotation_payload(
request: KeyRotationRequest,
vault: EncryptionVault,
algorithm_suite: str,
) -> dict[str, object]:
return {
"tenant_id": request.tenant_id,
"vault_id": request.vault_id,
"purpose": vault.purpose,
"algorithm_suite": algorithm_suite,
"reason": request.reason,
"policy_decision_ref": request.policy_decision_ref,
"assurance_evidence_ref": request.assurance_evidence_ref,
"provider_policy": dict(request.provider_policy),
"import_reference": None,
}
def _lifecycle_payload(
request: KeyLifecycleRequest,
operation: str,
) -> dict[str, object]:
return {
"tenant_id": request.tenant_id,
"vault_id": request.vault_id,
"key_version": request.key_version,
"operation": operation,
"reason": request.reason,
"policy_decision_ref": request.policy_decision_ref,
"assurance_evidence_ref": request.assurance_evidence_ref,
"effective_at": (
_as_utc(request.effective_at).isoformat()
if request.effective_at is not None
else None
),
}
def _migration_payload(request: ProtectionMigrationRequest) -> dict[str, object]:
return {
"tenant_id": request.tenant_id,
"envelope_id": request.envelope_id,
"target_provider_id": request.target_provider_id,
"target_vault_id": request.target_vault_id,
"target_key_version": request.target_key_version,
"target_algorithm_suite": request.target_algorithm_suite,
"mode": request.mode,
"policy_decision_ref": request.policy_decision_ref,
"assurance_evidence_ref": request.assurance_evidence_ref,
}
def _recovery_payload(request: RecoveryRequest) -> dict[str, object]:
return {
"tenant_id": request.tenant_id,
"vault_id": request.vault_id,
"reason": request.reason,
"requested_scope": request.requested_scope,
"policy_decision_ref": request.policy_decision_ref,
"assurance_evidence_ref": request.assurance_evidence_ref,
"expires_at": _as_utc(request.expires_at).isoformat(),
}
def _envelope_payload(
envelope: ContentProtectionEnvelope,
policy_decision_ref: str,
) -> dict[str, object]:
return {
"envelope_id": envelope.envelope_id,
"tenant_id": envelope.tenant_id,
"owner_module": envelope.owner_module,
"resource_type": envelope.resource_type,
"resource_id": envelope.resource_id,
"profile_kind": envelope.profile_kind,
"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,
"ciphertext_ref": envelope.ciphertext_ref,
"ciphertext_digest": envelope.ciphertext_digest,
"authenticated_context_digest": envelope.authenticated_context_digest,
"state": envelope.state,
"wrapped_key_refs": list(envelope.wrapped_key_refs),
"source_envelope_id": envelope.source_envelope_id,
"migration_id": envelope.migration_id,
"metadata": dict(envelope.metadata),
"policy_decision_ref": policy_decision_ref,
}
def _expected_revision(item: object, expected: int) -> None:
actual = int(getattr(item, "revision", 0))
if actual != expected:
raise EncryptionError(
f"The resource changed (expected revision {expected}, current {actual})."
)
def _require_tenant(principal: object, tenant_id: str) -> None:
principal_tenant = str(getattr(principal, "tenant_id", "") or "")
if not principal_tenant or principal_tenant != tenant_id:
raise EncryptionError("Cross-tenant encryption access is not allowed.")
def _account_id(principal: object) -> str:
user = getattr(principal, "user", None)
value = (
getattr(user, "id", None)
or getattr(principal, "account_id", None)
or getattr(principal, "api_key_id", None)
)
if value is None or not str(value).strip():
raise EncryptionError("An accountable principal is required.")
return str(value)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Encryption requires a SQLAlchemy Session.")
return value
def _digest(value: Mapping[str, object]) -> str:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _exception_code(exc: Exception) -> str:
return f"provider_{type(exc).__name__.lower()}"[:255]
def _safe_error_code(value: str | None) -> str | None:
if value is None:
return None
clean = "".join(
character for character in value if character.isalnum() or character in "._-"
)
return clean[:255] or "provider_error"
__all__ = [
"EncryptionError",
"SqlEncryptionService",
"TERMINAL_PROTECTION_STATES",
]