diff --git a/README.md b/README.md index 5cdf4e6..92bc488 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,12 @@ an independently readable copy in the next frozen function Postbox. The `govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when Celery beat and a worker consuming the `postbox` queue are enabled. -The persistence model reserves ciphertext manifests, wrapped keys, key epochs, -expiry, and withdrawal state. The active profile remains `plaintext_v1`; the -module does not claim end-to-end encryption until the history, recovery, and -handover policy choices in the security issues are resolved. +Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile. +The latter stores message bodies as ciphertext through the Encryption +capability and fails closed on reads if that capability or key is unavailable. +Subjects, participants, routing, attachment references, and lifecycle metadata +remain observable. Existing externally produced ciphertext references remain +supported, but neither path is described as end-to-end encryption. Run focused checks with: diff --git a/docs/POSTBOX_CONCEPT.md b/docs/POSTBOX_CONCEPT.md index 0c7e764..d6b8abc 100644 --- a/docs/POSTBOX_CONCEPT.md +++ b/docs/POSTBOX_CONCEPT.md @@ -12,10 +12,13 @@ needed a role, process, portal, campaign, or service responsibility. It may look like an inbox for a message task or like a vault for content shared with the current holders of that responsibility; neither form is owned by one account. -The strategic target is an encrypted administrative postbox. The first -implementation may start with ordinary persisted messages, but the model must -not prevent later end-to-end encryption, role/function key epochs, signed -manifests, external-recipient tokens, or honest retraction semantics. The +The strategic target is an encrypted administrative postbox. The current +implementation supports ordinary persisted messages and an optional +server-readable Encryption envelope for message bodies. The model also retains +external ciphertext, wrapped-key, signed-manifest, external-recipient-token, +and key-epoch metadata needed for later independently reviewed E2EE profiles. +The server-envelope profile is not E2EE, and subjects, routing, participants, +and attachment references remain visible. The cross-module target architecture is recorded in `govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`. @@ -330,6 +333,22 @@ The WebUI should start as an administration and inbox surface: Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable. +### Current content-protection profile + +An exact Postbox or template revision may select `server_envelope_v1` and an +Encryption vault. New locally authored and delivered message bodies are then +stored in `body_ciphertext` with an owner-bound envelope reference; clear body +text is not persisted. Reads ask the optional `encryption.content_cipher` +capability to open the exact tenant, message, and envelope tuple. Missing +Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering, +or a mismatched resource causes a fail-closed read. + +Plaintext Postboxes continue to work without Encryption. A protected Postbox +cannot silently fall back to plaintext. Database recovery of protected messages +requires Postbox and Encryption tables from the same consistency point plus the +matching provider/deployment key. Hierarchy-routed copies retain the source +envelope reference rather than decrypting and re-encrypting during routing. + ## E2EE Readiness Checklist Before the data model is considered stable, verify that it can represent: diff --git a/src/govoplan_postbox/backend/content_protection.py b/src/govoplan_postbox/backend/content_protection.py new file mode 100644 index 0000000..970f608 --- /dev/null +++ b/src/govoplan_postbox/backend/content_protection.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from govoplan_core.core.encryption import ( + ContentProtectionRequest, + ContentUnprotectionRequest, + ProtectedContent, + encryption_content_cipher, +) +from govoplan_postbox.backend.runtime import get_registry + + +POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1" +POSTBOX_SERVER_ENVELOPE_PROFILE = "server_envelope_v1" + + +class PostboxContentProtectionError(ValueError): + pass + + +def protect_message_body( + session: object, + *, + tenant_id: str, + message_id: str, + vault_id: str, + plaintext: str, + actor_id: str, +) -> ProtectedContent: + capability = encryption_content_cipher(get_registry()) + if capability is None: + raise PostboxContentProtectionError( + "Postbox encryption was requested, but Encryption is unavailable." + ) + try: + return capability.protect_content( + session, + request=ContentProtectionRequest( + tenant_id=tenant_id, + owner_module="postbox", + resource_type="postbox_message_body", + resource_id=message_id, + profile_id=POSTBOX_SERVER_ENVELOPE_PROFILE, + vault_id=vault_id, + ciphertext_ref=f"postbox-db://messages/{message_id}/body", + plaintext=plaintext.encode("utf-8"), + policy_decision_ref="postbox:configured-server-envelope:v1", + idempotency_key=f"postbox-message:{message_id}:body:v1", + actor_id=actor_id, + metadata={"content_type": "text/plain;charset=utf-8"}, + ), + ) + except Exception as exc: + raise PostboxContentProtectionError( + "Postbox message content could not be protected by its configured vault." + ) from exc + + +def unprotect_message_body( + session: object, + *, + tenant_id: str, + message_id: str, + envelope_id: str, + ciphertext: bytes, +) -> str: + capability = encryption_content_cipher(get_registry()) + if capability is None: + raise PostboxContentProtectionError( + "This Postbox message is encrypted and Encryption is unavailable." + ) + try: + plaintext = capability.unprotect_content( + session, + request=ContentUnprotectionRequest( + tenant_id=tenant_id, + owner_module="postbox", + resource_type="postbox_message_body", + resource_id=message_id, + envelope_id=envelope_id, + ciphertext=ciphertext, + ), + ) + return plaintext.decode("utf-8") + except Exception as exc: + raise PostboxContentProtectionError( + "Postbox message content could not be opened with its protection envelope." + ) from exc + + +__all__ = [ + "POSTBOX_PLAINTEXT_PROFILE", + "POSTBOX_SERVER_ENVELOPE_PROFILE", + "PostboxContentProtectionError", + "protect_message_body", + "unprotect_message_body", +] diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index 8357fc1..0fad7c5 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( Index, Integer, JSON, + LargeBinary, String, Text, UniqueConstraint, @@ -145,6 +146,9 @@ class PostboxTemplateRevision(Base, TimestampMixin): default="plaintext_v1", nullable=False, ) + encryption_vault_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) history_policy: Mapped[dict[str, Any]] = mapped_column( JSON, default=dict, @@ -420,6 +424,9 @@ class PostboxMessage(Base, TimestampMixin): ) subject: Mapped[str] = mapped_column(String(1000), nullable=False) body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + body_ciphertext: Mapped[bytes | None] = mapped_column( + LargeBinary, nullable=True + ) status: Mapped[str] = mapped_column( String(30), default="delivered", @@ -473,6 +480,12 @@ class PostboxMessage(Base, TimestampMixin): String(1000), nullable=True, ) + encryption_envelope_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + encryption_resource_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) signed_manifest_ref: Mapped[str | None] = mapped_column( String(1000), nullable=True, diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index d4a7b55..72b1a6a 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -7,6 +7,7 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY +from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER from govoplan_core.core.idm import ( CAPABILITY_IDM_DIRECTORY, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, @@ -212,6 +213,7 @@ manifest = ModuleManifest( "access", "audit", "campaigns", + "encryption", "files", "mail", "notifications", @@ -257,6 +259,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, @@ -368,9 +376,11 @@ manifest = ModuleManifest( "permission with effective IDM assignment context. Templates " "can lazily materialize unit-specific addresses, while exact " "postboxes cover exceptional responsibilities. The first " - "implementation persists plaintext but reserves explicit " - "ciphertext, signed-manifest, and key-epoch fields; it does not " - "claim end-to-end encryption until a trusted policy profile is selected." + "Plaintext Postboxes remain available without Encryption. A " + "server-envelope profile stores message bodies as ciphertext and " + "uses the optional Encryption capability for authorized reads. " + "External ciphertext profiles retain producer-managed references " + "and keys; neither profile is described as end-to-end encryption." ), layer="available", documentation_types=("admin", "user"), @@ -392,7 +402,11 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="docs/POSTBOX_CONCEPT.md", test_ref="tests/test_service.py", - known_limits=("End-to-end encryption and target deployment recovery profiles are intentionally not claimed.",), + known_limits=( + "Subjects, routing metadata, participants, and attachment references remain plaintext metadata.", + "Server-envelope protection is server-decryptable and is not end-to-end encryption.", + "External ciphertext profiles require a separately governed producer and client key-custody profile.", + ), owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"), non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"), recovery_docs=("docs/POSTBOX_CONCEPT.md",), diff --git a/src/govoplan_postbox/backend/migrations/versions/d8e3f6a9b2c5_postbox_content_protection.py b/src/govoplan_postbox/backend/migrations/versions/d8e3f6a9b2c5_postbox_content_protection.py new file mode 100644 index 0000000..c74eec6 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/d8e3f6a9b2c5_postbox_content_protection.py @@ -0,0 +1,55 @@ +"""postbox content-protection state + +Revision ID: d8e3f6a9b2c5 +Revises: a6d9e1f4c8b3 +Create Date: 2026-08-02 00:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d8e3f6a9b2c5" +down_revision = "a6d9e1f4c8b3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("postbox_template_revisions") as batch_op: + batch_op.add_column( + sa.Column("encryption_vault_id", sa.String(length=255), nullable=True) + ) + with op.batch_alter_table("postbox_messages") as batch_op: + batch_op.add_column( + sa.Column("body_ciphertext", sa.LargeBinary(), nullable=True) + ) + batch_op.add_column( + sa.Column("encryption_envelope_id", sa.String(length=255), nullable=True) + ) + batch_op.add_column( + sa.Column("encryption_resource_id", sa.String(length=36), nullable=True) + ) + batch_op.create_index( + op.f("ix_postbox_messages_encryption_envelope_id"), + ["encryption_envelope_id"], + unique=False, + ) + batch_op.create_index( + op.f("ix_postbox_messages_encryption_resource_id"), + ["encryption_resource_id"], + unique=False, + ) + + +def downgrade() -> None: + with op.batch_alter_table("postbox_messages") as batch_op: + batch_op.drop_index(op.f("ix_postbox_messages_encryption_resource_id")) + batch_op.drop_index(op.f("ix_postbox_messages_encryption_envelope_id")) + batch_op.drop_column("encryption_resource_id") + batch_op.drop_column("encryption_envelope_id") + batch_op.drop_column("body_ciphertext") + with op.batch_alter_table("postbox_template_revisions") as batch_op: + batch_op.drop_column("encryption_vault_id") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index 6304008..06ca434 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -257,6 +257,7 @@ def _template_item(template) -> PostboxTemplateItem: "classification": revision.classification, "allow_vacant_delivery": revision.allow_vacant_delivery, "encryption_profile": revision.encryption_profile, + "encryption_vault_id": revision.encryption_vault_id, "history_policy": dict(revision.history_policy or {}), "routing_policy": dict(revision.routing_policy or {}), "retention_policy": dict(revision.retention_policy or {}), diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index e198abd..245f438 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -355,6 +355,23 @@ class PostboxExactCreateRequest(BaseModel): function_id: str = Field(min_length=1, max_length=36) address_key: str | None = Field(default=None, max_length=120) classification: PostboxClassification = "internal" + encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( + "plaintext_v1" + ) + encryption_vault_id: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def validate_encryption(self) -> "PostboxExactCreateRequest": + if self.encryption_profile == "server_envelope_v1": + if not str(self.encryption_vault_id or "").strip(): + raise ValueError( + "Server-envelope Postboxes require an encryption vault." + ) + elif self.encryption_vault_id: + raise ValueError( + "A plaintext Postbox cannot select an encryption vault." + ) + return self class PostboxTemplateRevisionPayload(BaseModel): @@ -373,10 +390,27 @@ class PostboxTemplateRevisionPayload(BaseModel): ) classification: PostboxClassification = "internal" allow_vacant_delivery: bool = True + encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( + "plaintext_v1" + ) + encryption_vault_id: str | None = Field(default=None, max_length=255) routing_policy: PostboxRoutingPolicyPayload = Field( default_factory=PostboxRoutingPolicyPayload ) + @model_validator(mode="after") + def validate_encryption(self) -> "PostboxTemplateRevisionPayload": + if self.encryption_profile == "server_envelope_v1": + if not str(self.encryption_vault_id or "").strip(): + raise ValueError( + "Server-envelope Postbox templates require an encryption vault." + ) + elif self.encryption_vault_id: + raise ValueError( + "A plaintext Postbox template cannot select an encryption vault." + ) + return self + class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload): slug: str = Field(min_length=1, max_length=120) @@ -391,7 +425,6 @@ class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload): class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): id: str revision: int - encryption_profile: str history_policy: dict[str, Any] = Field(default_factory=dict) retention_policy: dict[str, Any] = Field(default_factory=dict) published_at: datetime | None = None diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 7b6b3b2..625d38a 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -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( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index f7db00f..e8556e7 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -10,6 +10,7 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_ROUTING, ) +from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER from govoplan_postbox.backend.manifest import get_manifest @@ -39,6 +40,14 @@ class PostboxManifestTests(unittest.TestCase): "idm.function_assignments", manifest.required_capabilities, ) + self.assertIn("encryption", manifest.optional_dependencies) + self.assertTrue( + any( + requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER + and requirement.optional + for requirement in manifest.requires_interfaces + ) + ) if __name__ == "__main__": diff --git a/tests/test_migration.py b/tests/test_migration.py index cb086d9..4e79cb3 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -26,6 +26,10 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "a6d9e1f4c8b3_v013_external_recipient_tokens" ) + protection_migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "d8e3f6a9b2c5_postbox_content_protection" + ) engine = create_engine("sqlite:///:memory:") try: with engine.begin() as connection: @@ -34,15 +38,18 @@ class PostboxMigrationTests(unittest.TestCase): route_original = route_migration.op occ_original = occ_migration.op envelope_original = envelope_migration.op + protection_original = protection_migration.op migration.op = operations route_migration.op = operations occ_migration.op = operations envelope_migration.op = operations + protection_migration.op = operations try: migration.upgrade() route_migration.upgrade() occ_migration.upgrade() envelope_migration.upgrade() + protection_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) @@ -63,8 +70,20 @@ class PostboxMigrationTests(unittest.TestCase): "key_epoch", "expires_at", "withdrawn_at", + "body_ciphertext", + "encryption_envelope_id", + "encryption_resource_id", }.issubset(message_columns) ) + self.assertIn( + "encryption_vault_id", + { + column["name"] + for column in inspect(connection).get_columns( + "postbox_template_revisions" + ) + }, + ) self.assertIn("authoring_key", message_columns) for table_name in ( "postbox_templates", @@ -91,6 +110,7 @@ class PostboxMigrationTests(unittest.TestCase): route_columns ) ) + protection_migration.downgrade() envelope_migration.downgrade() occ_migration.downgrade() route_migration.downgrade() @@ -107,6 +127,7 @@ class PostboxMigrationTests(unittest.TestCase): route_migration.op = route_original occ_migration.op = occ_original envelope_migration.op = envelope_original + protection_migration.op = protection_original finally: engine.dispose() diff --git a/tests/test_service.py b/tests/test_service.py index 21e4228..e4a049b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -2,6 +2,8 @@ from __future__ import annotations import unittest from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -51,6 +53,7 @@ from govoplan_postbox.backend.db.models import ( PostboxTemplate, PostboxTemplateRevision, ) +from govoplan_postbox.backend.content_protection import PostboxContentProtectionError from govoplan_postbox.backend.service import PostboxError, PostboxService @@ -1236,6 +1239,85 @@ class PostboxServiceTests(unittest.TestCase): ), ) + def test_server_envelope_body_is_not_persisted_in_plaintext_and_fails_closed( + self, + ) -> None: + self.idm.assignments.append(self.assignment) + protected = SimpleNamespace( + ciphertext=b"encrypted-message-body", + envelope=SimpleNamespace( + envelope_id="envelope-1", + ciphertext_ref="postbox-db://message/body", + algorithm_suite="AES-256-GCM", + wrapped_key_refs=("wrapped-key-1",), + ), + ) + with Session(self.engine) as session: + postbox = self.service.create_exact_postbox( + session, + tenant_id="tenant-1", + name="Protected intake", + organization_unit_id="unit-1", + function_id="function-1", + address_key=None, + description=None, + classification="internal", + actor_id="admin-1", + encryption_profile="server_envelope_v1", + encryption_vault_id="vault-1", + ) + with patch( + "govoplan_postbox.backend.content_protection.protect_message_body", + return_value=protected, + ): + delivered = self.service.deliver( + session, + PostboxDeliveryRequest( + tenant_id="tenant-1", + target=PostboxTargetRef(postbox_id=postbox.id), + producer_module="campaigns", + producer_resource_type="campaign_recipient", + producer_resource_id="recipient-protected", + idempotency_key="protected-message", + subject="Protected notice", + body_text="clear message body", + ), + ) + + stored = session.get(PostboxMessage, delivered.message_id) + assert stored is not None + self.assertIsNone(stored.body_text) + self.assertEqual(b"encrypted-message-body", stored.body_ciphertext) + self.assertEqual("envelope-1", stored.encryption_envelope_id) + + with patch( + "govoplan_postbox.backend.content_protection.unprotect_message_body", + return_value="clear message body", + ): + opened = self.service.get_message( + session, + tenant_id="tenant-1", + message_id=delivered.message_id, + actor=self.actor, + ) + assert opened is not None + self.assertEqual("clear message body", opened.body_text) + + with patch( + "govoplan_postbox.backend.content_protection.unprotect_message_body", + side_effect=PostboxContentProtectionError("provider unavailable"), + ): + with self.assertRaisesRegex( + PostboxError, + "provider unavailable", + ): + self.service.get_message( + session, + tenant_id="tenant-1", + message_id=delivered.message_id, + actor=self.actor, + ) + def test_hierarchy_linked_copy_snapshots_path_and_independent_state( self, ) -> None: