feat: protect postbox message content

This commit is contained in:
2026-08-02 03:40:57 +02:00
parent 7d310d5c33
commit 8f8d259a76
12 changed files with 611 additions and 29 deletions
+253 -16
View File
@@ -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(