feat: protect postbox message content
This commit is contained in:
@@ -89,6 +89,7 @@ from govoplan_postbox.backend.db.models import (
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_postbox.backend.hierarchy_routing import (
|
||||
HierarchyRouteCandidate,
|
||||
@@ -130,6 +131,28 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _validate_encryption_configuration(
|
||||
profile: str,
|
||||
vault_id: str | None,
|
||||
) -> None:
|
||||
normalized = str(profile or "").strip()
|
||||
if normalized not in {"plaintext_v1", "server_envelope_v1"}:
|
||||
raise PostboxError(
|
||||
"unsupported_encryption_profile",
|
||||
"Unsupported Postbox encryption profile.",
|
||||
)
|
||||
if normalized == "server_envelope_v1" and not str(vault_id or "").strip():
|
||||
raise PostboxError(
|
||||
"encryption_vault_missing",
|
||||
"Server-envelope Postboxes require an encryption vault.",
|
||||
)
|
||||
if normalized == "plaintext_v1" and vault_id:
|
||||
raise PostboxError(
|
||||
"encryption_profile_mismatch",
|
||||
"A plaintext Postbox cannot select an encryption vault.",
|
||||
)
|
||||
|
||||
|
||||
def _optional_datetime(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
@@ -282,6 +305,84 @@ class PostboxService:
|
||||
self._hierarchy = hierarchy
|
||||
self._notifications = notifications
|
||||
|
||||
@staticmethod
|
||||
def _message_body_storage(
|
||||
session: Session,
|
||||
*,
|
||||
postbox: Postbox,
|
||||
message_id: str,
|
||||
body_text: str | None,
|
||||
actor_id: str,
|
||||
external_ciphertext_ref: str | None = None,
|
||||
external_wrapped_keys: Sequence[object] = (),
|
||||
) -> dict[str, object]:
|
||||
profile = str(postbox.encryption_profile or "plaintext_v1").strip()
|
||||
if external_ciphertext_ref:
|
||||
return {
|
||||
"body_text": None,
|
||||
"body_ciphertext": None,
|
||||
"ciphertext_ref": external_ciphertext_ref,
|
||||
"encryption_envelope_id": None,
|
||||
"encryption_resource_id": None,
|
||||
"wrapped_keys": [asdict(item) for item in external_wrapped_keys],
|
||||
}
|
||||
if body_text is None or profile == "plaintext_v1":
|
||||
return {
|
||||
"body_text": body_text,
|
||||
"body_ciphertext": None,
|
||||
"ciphertext_ref": None,
|
||||
"encryption_envelope_id": None,
|
||||
"encryption_resource_id": None,
|
||||
"wrapped_keys": [],
|
||||
}
|
||||
if profile != "server_envelope_v1":
|
||||
raise PostboxError(
|
||||
"unsupported_encryption_profile",
|
||||
"This Postbox protection profile requires an external content producer.",
|
||||
)
|
||||
settings = postbox.settings if isinstance(postbox.settings, Mapping) else {}
|
||||
vault_id = str(settings.get("encryption_vault_id") or "").strip()
|
||||
if not vault_id:
|
||||
raise PostboxError(
|
||||
"encryption_vault_missing",
|
||||
"The Postbox server-envelope profile has no configured vault.",
|
||||
)
|
||||
from govoplan_postbox.backend.content_protection import (
|
||||
PostboxContentProtectionError,
|
||||
protect_message_body,
|
||||
)
|
||||
|
||||
try:
|
||||
protected = protect_message_body(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
message_id=message_id,
|
||||
vault_id=vault_id,
|
||||
plaintext=body_text,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
except PostboxContentProtectionError as exc:
|
||||
raise PostboxError("content_protection_failed", str(exc)) from exc
|
||||
envelope = protected.envelope
|
||||
return {
|
||||
"body_text": None,
|
||||
"body_ciphertext": protected.ciphertext,
|
||||
"ciphertext_ref": envelope.ciphertext_ref,
|
||||
"encryption_envelope_id": envelope.envelope_id,
|
||||
"encryption_resource_id": message_id,
|
||||
"wrapped_keys": [
|
||||
{
|
||||
"recipient_type": "vault",
|
||||
"recipient_id": vault_id,
|
||||
"key_epoch": postbox.key_epoch,
|
||||
"wrapped_key_ref": wrapped_key_ref,
|
||||
"algorithm": envelope.algorithm_suite,
|
||||
"metadata": {"envelope_id": envelope.envelope_id},
|
||||
}
|
||||
for wrapped_key_ref in envelope.wrapped_key_refs
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_registry(cls, registry: PlatformRegistry) -> "PostboxService":
|
||||
identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
@@ -1014,11 +1115,21 @@ class PostboxService:
|
||||
"assignment_id": decision.selected_assignment_id,
|
||||
"action": action,
|
||||
}
|
||||
message_id = new_uuid()
|
||||
body_storage = self._message_body_storage(
|
||||
session,
|
||||
postbox=postbox,
|
||||
message_id=message_id,
|
||||
body_text=request.body_text,
|
||||
actor_id=actor.account_id,
|
||||
)
|
||||
message = PostboxMessage(
|
||||
id=message_id,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
subject=request.subject.strip() or "(No subject)",
|
||||
body_text=request.body_text,
|
||||
body_text=body_storage["body_text"],
|
||||
body_ciphertext=body_storage["body_ciphertext"],
|
||||
status="sent",
|
||||
classification=classification,
|
||||
sender_label=sender_label,
|
||||
@@ -1029,6 +1140,10 @@ class PostboxService:
|
||||
in_reply_to_message_id=(in_reply_to.id if in_reply_to else None),
|
||||
encryption_profile=postbox.encryption_profile,
|
||||
key_epoch=postbox.key_epoch,
|
||||
ciphertext_ref=body_storage["ciphertext_ref"],
|
||||
encryption_envelope_id=body_storage["encryption_envelope_id"],
|
||||
encryption_resource_id=body_storage["encryption_resource_id"],
|
||||
wrapped_keys=body_storage["wrapped_keys"],
|
||||
delivered_at=utc_now(),
|
||||
metadata_=metadata,
|
||||
)
|
||||
@@ -1175,22 +1290,40 @@ class PostboxService:
|
||||
)
|
||||
|
||||
now = utc_now()
|
||||
message_id = new_uuid()
|
||||
body_storage = self._message_body_storage(
|
||||
db,
|
||||
postbox=postbox,
|
||||
message_id=message_id,
|
||||
body_text=request.body_text,
|
||||
actor_id=f"module:{request.producer_module}",
|
||||
external_ciphertext_ref=request.ciphertext_ref,
|
||||
external_wrapped_keys=request.wrapped_keys,
|
||||
)
|
||||
message = PostboxMessage(
|
||||
id=message_id,
|
||||
tenant_id=request.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
subject=request.subject.strip() or "(No subject)",
|
||||
body_text=request.body_text,
|
||||
body_text=body_storage["body_text"],
|
||||
body_ciphertext=body_storage["body_ciphertext"],
|
||||
status="delivered",
|
||||
classification=classification,
|
||||
sender_label=request.sender_label,
|
||||
producer_module=request.producer_module,
|
||||
producer_resource_type=request.producer_resource_type,
|
||||
producer_resource_id=request.producer_resource_id,
|
||||
encryption_profile=postbox.encryption_profile,
|
||||
encryption_profile=(
|
||||
"external_envelope_v1"
|
||||
if request.ciphertext_ref
|
||||
else postbox.encryption_profile
|
||||
),
|
||||
key_epoch=postbox.key_epoch,
|
||||
ciphertext_ref=request.ciphertext_ref,
|
||||
ciphertext_ref=body_storage["ciphertext_ref"],
|
||||
encryption_envelope_id=body_storage["encryption_envelope_id"],
|
||||
encryption_resource_id=body_storage["encryption_resource_id"],
|
||||
signed_manifest_ref=request.signed_manifest_ref,
|
||||
wrapped_keys=[asdict(item) for item in request.wrapped_keys],
|
||||
wrapped_keys=body_storage["wrapped_keys"],
|
||||
external_recipient_tokens=[
|
||||
_external_token_record(item)
|
||||
for item in request.external_recipient_tokens
|
||||
@@ -1845,22 +1978,48 @@ class PostboxService:
|
||||
route: PostboxRoute,
|
||||
delivered_at: datetime,
|
||||
) -> PostboxMessage:
|
||||
message_id = new_uuid()
|
||||
if source_message.ciphertext_ref:
|
||||
body_storage = {
|
||||
"body_text": None,
|
||||
"body_ciphertext": source_message.body_ciphertext,
|
||||
"ciphertext_ref": source_message.ciphertext_ref,
|
||||
"encryption_envelope_id": source_message.encryption_envelope_id,
|
||||
"encryption_resource_id": (
|
||||
source_message.encryption_resource_id or source_message.id
|
||||
),
|
||||
"wrapped_keys": list(source_message.wrapped_keys or []),
|
||||
}
|
||||
encryption_profile = source_message.encryption_profile
|
||||
else:
|
||||
body_storage = self._message_body_storage(
|
||||
session,
|
||||
postbox=target_postbox,
|
||||
message_id=message_id,
|
||||
body_text=source_message.body_text,
|
||||
actor_id=f"module:{source_message.producer_module or 'postbox'}",
|
||||
)
|
||||
encryption_profile = target_postbox.encryption_profile
|
||||
message = PostboxMessage(
|
||||
id=message_id,
|
||||
tenant_id=source_message.tenant_id,
|
||||
postbox_id=target_postbox.id,
|
||||
subject=source_message.subject,
|
||||
body_text=source_message.body_text,
|
||||
body_text=body_storage["body_text"],
|
||||
body_ciphertext=body_storage["body_ciphertext"],
|
||||
status="delivered",
|
||||
classification=source_message.classification,
|
||||
sender_label=source_message.sender_label,
|
||||
producer_module=source_message.producer_module,
|
||||
producer_resource_type=source_message.producer_resource_type,
|
||||
producer_resource_id=source_message.producer_resource_id,
|
||||
encryption_profile=target_postbox.encryption_profile,
|
||||
encryption_profile=encryption_profile,
|
||||
key_epoch=target_postbox.key_epoch,
|
||||
ciphertext_ref=source_message.ciphertext_ref,
|
||||
ciphertext_ref=body_storage["ciphertext_ref"],
|
||||
encryption_envelope_id=body_storage["encryption_envelope_id"],
|
||||
encryption_resource_id=body_storage["encryption_resource_id"],
|
||||
signed_manifest_ref=source_message.signed_manifest_ref,
|
||||
wrapped_keys=list(source_message.wrapped_keys or []),
|
||||
wrapped_keys=body_storage["wrapped_keys"],
|
||||
external_recipient_tokens=list(
|
||||
source_message.external_recipient_tokens or []
|
||||
),
|
||||
@@ -2563,8 +2722,14 @@ class PostboxService:
|
||||
description: str | None,
|
||||
classification: str,
|
||||
actor_id: str | None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
) -> Postbox:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
encryption_vault_id,
|
||||
)
|
||||
unit, function = self._validate_function_target(
|
||||
tenant_id=tenant_id,
|
||||
organization_unit_id=organization_unit_id,
|
||||
@@ -2601,6 +2766,8 @@ class PostboxService:
|
||||
revision=None,
|
||||
source="exact",
|
||||
actor_id=actor_id,
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
)
|
||||
|
||||
def archive_postbox(
|
||||
@@ -2690,8 +2857,14 @@ class PostboxService:
|
||||
allow_vacant_delivery: bool,
|
||||
actor_id: str | None,
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
encryption_vault_id,
|
||||
)
|
||||
clean_slug = _slug(slug or name, fallback="template")
|
||||
if (
|
||||
session.query(PostboxTemplate)
|
||||
@@ -2731,7 +2904,8 @@ class PostboxService:
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
allow_vacant_delivery=allow_vacant_delivery,
|
||||
encryption_profile="plaintext_v1",
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
history_policy={},
|
||||
routing_policy=normalized_routing_policy(routing_policy),
|
||||
retention_policy={},
|
||||
@@ -2768,8 +2942,14 @@ class PostboxService:
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
encryption_vault_id,
|
||||
)
|
||||
template = self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -2809,7 +2989,8 @@ class PostboxService:
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
allow_vacant_delivery=allow_vacant_delivery,
|
||||
encryption_profile="plaintext_v1",
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
history_policy={},
|
||||
routing_policy=normalized_routing_policy(routing_policy),
|
||||
retention_policy={},
|
||||
@@ -3754,12 +3935,42 @@ class PostboxService:
|
||||
)
|
||||
availability = _message_availability(message)
|
||||
content_available = availability == "available"
|
||||
body_text = message.body_text if content_available else None
|
||||
if (
|
||||
content_available
|
||||
and message.encryption_envelope_id
|
||||
and message.body_ciphertext is not None
|
||||
):
|
||||
session = object_session(message)
|
||||
if session is None:
|
||||
raise PostboxError(
|
||||
"encrypted_content_unavailable",
|
||||
"Encrypted Postbox content requires an attached database session.",
|
||||
)
|
||||
from govoplan_postbox.backend.content_protection import (
|
||||
PostboxContentProtectionError,
|
||||
unprotect_message_body,
|
||||
)
|
||||
|
||||
try:
|
||||
body_text = unprotect_message_body(
|
||||
session,
|
||||
tenant_id=message.tenant_id,
|
||||
message_id=message.encryption_resource_id or message.id,
|
||||
envelope_id=message.encryption_envelope_id,
|
||||
ciphertext=message.body_ciphertext,
|
||||
)
|
||||
except PostboxContentProtectionError as exc:
|
||||
raise PostboxError(
|
||||
"encrypted_content_unavailable",
|
||||
str(exc),
|
||||
) from exc
|
||||
return PostboxMessageRef(
|
||||
id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
postbox_id=message.postbox_id,
|
||||
subject=message.subject,
|
||||
body_text=message.body_text if content_available else None,
|
||||
body_text=body_text,
|
||||
status=message.status,
|
||||
availability=availability,
|
||||
classification=message.classification,
|
||||
@@ -3938,7 +4149,31 @@ class PostboxService:
|
||||
revision: PostboxTemplateRevision | None,
|
||||
source: str,
|
||||
actor_id: str | None,
|
||||
encryption_profile: str | None = None,
|
||||
encryption_vault_id: str | None = None,
|
||||
) -> Postbox:
|
||||
effective_profile = (
|
||||
revision.encryption_profile
|
||||
if revision is not None
|
||||
else str(encryption_profile or "plaintext_v1")
|
||||
)
|
||||
effective_vault_id = (
|
||||
revision.encryption_vault_id
|
||||
if revision is not None
|
||||
else encryption_vault_id
|
||||
)
|
||||
if effective_profile == "server_envelope_v1" and not str(
|
||||
effective_vault_id or ""
|
||||
).strip():
|
||||
raise PostboxError(
|
||||
"encryption_vault_missing",
|
||||
"Server-envelope Postboxes require an encryption vault.",
|
||||
)
|
||||
if effective_profile not in {"plaintext_v1", "server_envelope_v1"}:
|
||||
raise PostboxError(
|
||||
"unsupported_encryption_profile",
|
||||
"Unsupported Postbox encryption profile.",
|
||||
)
|
||||
address_record = PostboxAddress(
|
||||
tenant_id=tenant_id,
|
||||
address_key=address_key,
|
||||
@@ -3960,11 +4195,13 @@ class PostboxService:
|
||||
description=description,
|
||||
status="active",
|
||||
classification=classification,
|
||||
encryption_profile=(
|
||||
revision.encryption_profile if revision else "plaintext_v1"
|
||||
),
|
||||
encryption_profile=effective_profile,
|
||||
key_epoch=1,
|
||||
settings={},
|
||||
settings=(
|
||||
{"encryption_vault_id": str(effective_vault_id)}
|
||||
if effective_vault_id
|
||||
else {}
|
||||
),
|
||||
)
|
||||
postbox.bindings.append(
|
||||
PostboxBinding(
|
||||
|
||||
Reference in New Issue
Block a user