diff --git a/src/govoplan_campaign/backend/archive_encryption.py b/src/govoplan_campaign/backend/archive_encryption.py new file mode 100644 index 0000000..82361e8 --- /dev/null +++ b/src/govoplan_campaign/backend/archive_encryption.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +import json +from typing import Any, Mapping + +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.policy import ( + CampaignArchiveEncryptionDecision, + CampaignArchiveEncryptionRequest, + campaign_archive_encryption_policy, +) +from govoplan_campaign.backend.db.models import Campaign +from govoplan_campaign.backend.runtime import get_registry + + +LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto" +LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption" + + +class CampaignArchiveEncryptionError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class EffectiveArchiveEncryptionPolicy: + available: bool + allowed_password_encryption_methods: frozenset[str] + allowed_password_delivery_channels: frozenset[str] + policy_hash: str + source_path: tuple[Mapping[str, Any], ...] + reason: str + diagnostics: tuple[Mapping[str, Any], ...] = () + + def to_dict(self) -> dict[str, Any]: + return { + "available": self.available, + "allowed_password_encryption_methods": sorted( + self.allowed_password_encryption_methods + ), + "allowed_password_delivery_channels": sorted( + self.allowed_password_delivery_channels + ), + "policy_hash": self.policy_hash, + "source_path": [dict(item) for item in self.source_path], + "reason": self.reason, + "diagnostics": [dict(item) for item in self.diagnostics], + "legacy_label": LEGACY_ZIPCRYPTO_LABEL, + } + + +def effective_archive_encryption_policy( + session: Session, + campaign: Campaign, +) -> EffectiveArchiveEncryptionPolicy: + provider = campaign_archive_encryption_policy(get_registry()) + if provider is None: + payload = { + "available": False, + "allowed_password_encryption_methods": ["aes"], + "allowed_password_delivery_channels": [ + "in_person", + "letter", + "phone", + "separate_mail", + "sms", + ], + "source_path": [ + { + "scope_type": "system", + "scope_id": None, + "path": "system", + "label": "Secure local fallback", + "applied_fields": ["allowed_password_encryption_methods"], + "policy": { + "allowed_password_encryption_methods": ["aes"], + "policy_provider": "unavailable", + }, + } + ], + } + return EffectiveArchiveEncryptionPolicy( + available=False, + allowed_password_encryption_methods=frozenset({"aes"}), + allowed_password_delivery_channels=frozenset( + {"separate_mail", "sms", "letter", "phone", "in_person"} + ), + policy_hash=_hash(payload), + source_path=tuple(payload["source_path"]), + reason=( + "Policy is unavailable. AES remains available through the secure " + "local baseline; legacy ZipCrypto fails closed." + ), + ) + owner_type: str | None = None + owner_id: str | None = None + if campaign.owner_group_id: + owner_type, owner_id = "group", campaign.owner_group_id + elif campaign.owner_user_id: + owner_type, owner_id = "user", campaign.owner_user_id + decision: CampaignArchiveEncryptionDecision = ( + provider.resolve_campaign_archive_encryption( + session, + request=CampaignArchiveEncryptionRequest( + tenant_id=campaign.tenant_id, + campaign_id=campaign.id, + owner_type=owner_type, # type: ignore[arg-type] + owner_id=owner_id, + ), + ) + ) + return EffectiveArchiveEncryptionPolicy( + available=True, + allowed_password_encryption_methods=frozenset( + decision.allowed_password_encryption_methods + ), + allowed_password_delivery_channels=frozenset( + decision.allowed_password_delivery_channels + ), + policy_hash=decision.policy_hash, + source_path=tuple(step.to_dict() for step in decision.source_path), + reason=decision.reason or "Effective archive-encryption policy resolved.", + diagnostics=decision.diagnostics, + ) + + +def assert_archive_encryption_allowed( + session: Session, + campaign: Campaign, + raw_json: Mapping[str, Any], + *, + principal: ApiPrincipal | None = None, +) -> EffectiveArchiveEncryptionPolicy: + policy = effective_archive_encryption_policy(session, campaign) + for archive in _archive_configs(raw_json): + method = str(archive.get("method") or "aes") + if method not in policy.allowed_password_encryption_methods: + raise CampaignArchiveEncryptionError( + f"{_method_label(method)} is blocked. {policy.reason}" + ) + if method == "zip_standard": + if not policy.available: + raise CampaignArchiveEncryptionError( + "Legacy ZipCrypto cannot be used while Policy is unavailable." + ) + if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE): + raise CampaignArchiveEncryptionError( + f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}" + ) + if not archive.get("legacy_zipcrypto_acknowledged"): + raise CampaignArchiveEncryptionError( + f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement." + ) + if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10: + raise CampaignArchiveEncryptionError( + f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters." + ) + if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get( + "legacy_zipcrypto_acknowledged_at" + ): + raise CampaignArchiveEncryptionError( + "Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again." + ) + if archive.get("password_enabled"): + # Existing campaign revisions predate the explicit field. Their + # model default is the separate-mail channel; apply the same + # normalization before policy enforcement so saved revisions do + # not become unusable merely because the field was omitted. + channel = str( + archive.get("password_delivery_channel") or "separate_mail" + ) + if channel not in policy.allowed_password_delivery_channels: + raise CampaignArchiveEncryptionError( + f"Password-delivery channel {channel!r} is blocked by the effective policy." + ) + return policy + + +def stamp_legacy_zipcrypto_acknowledgements( + session: Session, + campaign: Campaign, + current_raw_json: Mapping[str, Any], + candidate_raw_json: dict[str, Any] | None, + *, + principal: ApiPrincipal, +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + if candidate_raw_json is None: + return None, [] + candidate = copy.deepcopy(candidate_raw_json) + current_by_id = { + str(item.get("id") or index): item + for index, item in enumerate(_archive_configs(current_raw_json)) + } + acknowledgements: list[dict[str, Any]] = [] + policy = effective_archive_encryption_policy(session, campaign) + archives = _archive_configs(candidate) + for index, archive in enumerate(archives): + if str(archive.get("method") or "aes") != "zip_standard": + archive.pop("legacy_zipcrypto_acknowledged_by", None) + archive.pop("legacy_zipcrypto_acknowledged_at", None) + continue + if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE): + raise CampaignArchiveEncryptionError( + f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}" + ) + if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods: + raise CampaignArchiveEncryptionError( + f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}" + ) + reason = str(archive.get("legacy_zipcrypto_reason") or "").strip() + if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10: + raise CampaignArchiveEncryptionError( + f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters." + ) + key = str(archive.get("id") or index) + previous = current_by_id.get(key, {}) + unchanged = ( + previous.get("method") == "zip_standard" + and previous.get("legacy_zipcrypto_acknowledged") is True + and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason + and previous.get("legacy_zipcrypto_acknowledged_by") + and previous.get("legacy_zipcrypto_acknowledged_at") + ) + if unchanged: + archive["legacy_zipcrypto_acknowledged_by"] = previous[ + "legacy_zipcrypto_acknowledged_by" + ] + archive["legacy_zipcrypto_acknowledged_at"] = previous[ + "legacy_zipcrypto_acknowledged_at" + ] + else: + archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id + archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat() + acknowledgements.append( + { + "archive_id": key, + "reason": reason, + "policy_hash": policy.policy_hash, + } + ) + return candidate, acknowledgements + + +def has_password_archives(raw_json: Mapping[str, Any]) -> bool: + return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json)) + + +def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]: + attachments = raw_json.get("attachments") + zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None + archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None + if isinstance(archives, list): + return [item for item in archives if isinstance(item, dict)] + return [] + + +def _method_label(method: str) -> str: + return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper() + + +def _hash(value: object) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode() + ).hexdigest() + + +__all__ = [ + "CampaignArchiveEncryptionError", + "EffectiveArchiveEncryptionPolicy", + "LEGACY_ZIPCRYPTO_LABEL", + "LEGACY_ZIPCRYPTO_SCOPE", + "assert_archive_encryption_allowed", + "effective_archive_encryption_policy", + "has_password_archives", + "stamp_legacy_zipcrypto_acknowledgements", +] diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index c6ab383..e56ea38 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -76,6 +76,14 @@ class ZipPasswordScope(StrEnum): GLOBAL = "global" +class ZipPasswordDeliveryChannel(StrEnum): + SEPARATE_MAIL = "separate_mail" + SMS = "sms" + LETTER = "letter" + PHONE = "phone" + IN_PERSON = "in_person" + + class ZipPasswordMode(StrEnum): NONE = "none" DIRECT = "direct" @@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel): password_field: str | None = None password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL method: ZipMethod = ZipMethod.AES + password_delivery_channel: ZipPasswordDeliveryChannel = ( + ZipPasswordDeliveryChannel.SEPARATE_MAIL + ) + legacy_zipcrypto_acknowledged: bool = False + legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000) + legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255) + legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80) # Compatibility fields for campaigns created by the first single-archive # implementation. New WebUI campaigns use password_enabled/field/scope. @@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel): normalized["password_scope"] = ZipPasswordScope.LOCAL.value return normalized + @model_validator(mode="after") + def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig": + if self.method != ZipMethod.ZIP_STANDARD: + return self + if not self.legacy_zipcrypto_acknowledged: + raise ValueError( + "Legacy ZipCrypto requires explicit acknowledgement of its weak encryption" + ) + if len((self.legacy_zipcrypto_reason or "").strip()) < 10: + raise ValueError( + "Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters" + ) + return self + class ZipCollectionConfig(StrictModel): enabled: bool = False diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 8dcf8a3..d7c6000 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -159,6 +159,12 @@ PERMISSIONS = ( "Build exact messages and attachment evidence.", "Campaigns", ), + _permission( + "campaigns:archive:use_legacy_zipcrypto", + "Use legacy ZipCrypto", + "Explicitly select weak Windows-compatible ZipCrypto when the effective policy permits it.", + "Campaign governance", + ), _permission( "campaigns:campaign:review", "Complete campaign review", @@ -1025,6 +1031,33 @@ manifest = ModuleManifest( ], }, ), + DocumentationTopic( + id="campaigns.archive-encryption-governance", + title="Use governed password-protected ZIP attachments", + summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.", + body=( + "Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure." + ), + documentation_types=("user", "admin"), + audience=("campaign_manager", "campaign_reviewer", "policy_admin"), + conditions=( + DocumentationCondition( + required_modules=("campaigns",), + any_scopes=( + "campaigns:campaign:update", + "campaigns:campaign:review", + "admin:policies:read", + ), + ), + ), + related_modules=("policy", "audit", "access"), + metadata={ + "kind": "workflow", + "route": "/campaigns/{campaign_id}/files", + "screen": "Campaign attachments", + "help_contexts": ["campaign.archive-encryption"], + }, + ), DocumentationTopic( id="campaigns.workflow.complete-review", title="Inspect built messages and complete review", diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index e9e479b..fbee40d 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -4,7 +4,7 @@ import mimetypes import re import tempfile import time -from dataclasses import dataclass +from dataclasses import dataclass, field from email.message import EmailMessage from email.utils import make_msgid, formatdate from pathlib import Path @@ -40,7 +40,10 @@ from govoplan_campaign.backend.campaign.models import ( effective_delivery_channel_policy, ) from govoplan_campaign.backend.campaign.template_values import build_template_values -from govoplan_campaign.backend.services.zip_service import create_zip_archive +from govoplan_campaign.backend.services.zip_service import ( + create_zip_archive, + zip_archive_evidence, +) from govoplan_campaign.backend.template_rendering import ( find_unresolved_placeholders as _find_unresolved_placeholders, render_template as _render_template, @@ -93,6 +96,7 @@ class _MimeBuildResult: build_status: BuildStatus validation_status: MessageValidationStatus attachment_count: int + archive_evidence: list[dict[str, object]] = field(default_factory=list) @dataclass(slots=True) @@ -372,8 +376,9 @@ def _attach_files( resolution: EntryAttachmentResolution, values: dict[str, Any], work_dir: Path, -) -> int: +) -> tuple[int, list[dict[str, object]]]: attached_count = 0 + evidence: list[dict[str, object]] = [] archive_members: dict[str, list[tuple[Path, str]]] = {} archive_attachments: dict[str, list[ResolvedAttachment]] = {} used_message_filenames: set[str] = set() @@ -429,13 +434,38 @@ def _attach_files( password, archive.method.value, ) + archive_record = zip_archive_evidence( + archive_path, + members, + password_protected=bool(password), + method=archive.method.value, + ) + archive_record.update( + { + "archive_id": archive.id, + "filename": filename, + "password_delivery_channel": ( + archive.password_delivery_channel.value if password else None + ), + "legacy_acknowledgement": ( + { + "actor_id": archive.legacy_zipcrypto_acknowledged_by, + "reason": archive.legacy_zipcrypto_reason, + "recorded_at": archive.legacy_zipcrypto_acknowledged_at, + } + if archive.method.value == "zip_standard" + else None + ), + } + ) + evidence.append(archive_record) data, maintype, subtype = _attachment_bytes(archive_path) message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) attached_count += 1 for attachment in archive_attachments.get(archive.id, []): attachment.zip_filename = filename - return attached_count + return attached_count, evidence def _imap_initial_status( config: CampaignConfig, @@ -527,6 +557,7 @@ def _message_draft( imap_status: ImapStatus | None = None, subject: str | None = None, attachment_count: int = 0, + archive_evidence: list[dict[str, object]] | None = None, issues: list[MessageIssue] | None = None, eml_path: str | None = None, eml_size: int | None = None, @@ -566,6 +597,7 @@ def _message_draft( disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]), attachment_count=attachment_count, attachments=_attachment_summaries(context.resolution), + archive_evidence=archive_evidence or [], issues=issues if issues is not None else context.issues, eml_path=eml_path, eml_size_bytes=eml_size, @@ -763,7 +795,7 @@ def _build_mime_message( _populate_message_body(message, rendered) if work_dir is None: work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-")) - attachment_count = _attach_files( + attachment_count, archive_evidence = _attach_files( message=message, config=config, entry=entry, @@ -789,6 +821,7 @@ def _build_mime_message( build_status=BuildStatus.BUILT, validation_status=context.validation_status, attachment_count=attachment_count, + archive_evidence=archive_evidence, ) except ZipBuildError as exc: context.issues.append( @@ -893,6 +926,7 @@ def build_entry_message( validation_status=mime_result.validation_status, subject=rendered.subject, attachment_count=mime_result.attachment_count, + archive_evidence=mime_result.archive_evidence, eml_path=eml_path, eml_size=eml_size, ) @@ -1097,6 +1131,7 @@ def _build_residual_file_message( validation_status=mime_result.validation_status, subject=rendered.subject, attachment_count=mime_result.attachment_count, + archive_evidence=mime_result.archive_evidence, eml_path=eml_path, eml_size=eml_size, ), diff --git a/src/govoplan_campaign/backend/messages/models.py b/src/govoplan_campaign/backend/messages/models.py index 120278d..15ebd4d 100644 --- a/src/govoplan_campaign/backend/messages/models.py +++ b/src/govoplan_campaign/backend/messages/models.py @@ -94,6 +94,7 @@ class MessageDraft(BaseModel): attachment_count: int = 0 attachments: list[MessageAttachmentSummary] = Field(default_factory=list) + archive_evidence: list[dict[str, object]] = Field(default_factory=list) issues: list[MessageIssue] = Field(default_factory=list) eml_path: str | None = None diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 0c3806d..929ab2d 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import ( JobSendStatus, JobValidationStatus, ) +from govoplan_campaign.backend.archive_encryption import ( + CampaignArchiveEncryptionError, + assert_archive_encryption_allowed, +) from govoplan_campaign.backend.campaign.loader import ( load_campaign_json, validate_against_schema, @@ -657,6 +661,15 @@ def validate_campaign_version( raise CampaignPersistenceError( "Campaign version is not accessible for this tenant" ) + try: + archive_policy = assert_archive_encryption_allowed( + session, + campaign, + version.raw_json if isinstance(version.raw_json, dict) else {}, + principal=principal, + ) + except CampaignArchiveEncryptionError as exc: + raise CampaignPersistenceError(str(exc)) from exc _ensure_current_campaign_version(campaign, version, action="validate") if _version_is_user_locked(version) or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, @@ -734,6 +747,7 @@ def validate_campaign_version( "warning_count": report.warning_count, "validated_at": datetime.now(UTC).isoformat(), "validated_by_user_id": user_id, + "archive_encryption_policy": archive_policy.to_dict(), } ) version.validation_summary = report_json @@ -1430,6 +1444,11 @@ def _store_execution_snapshot( delivery=config.delivery, jobs=jobs, build_summary=build_summary, + archive_encryption=( + build_summary.get("archive_encryption") + if isinstance(build_summary.get("archive_encryption"), dict) + else None + ), ) version.execution_snapshot = snapshot version.execution_snapshot_hash = snapshot_hash @@ -1615,6 +1634,15 @@ def build_campaign_version( raise CampaignPersistenceError( "Campaign version is not accessible for this tenant" ) + try: + archive_policy = assert_archive_encryption_allowed( + session, + campaign, + version.raw_json if isinstance(version.raw_json, dict) else {}, + principal=principal, + ) + except CampaignArchiveEncryptionError as exc: + raise CampaignPersistenceError(str(exc)) from exc _ensure_current_campaign_version(campaign, version, action="build") if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") @@ -1749,6 +1777,25 @@ def build_campaign_version( ) report_json = _campaign_build_report(result, files) report_json["built_by_user_id"] = user_id + archive_records = [ + { + **archive, + "campaign_id": campaign.id, + "campaign_version_id": version.id, + "build_token": report_json["build_token"], + "built_at": report_json["built_at"], + "policy_hash": archive_policy.policy_hash, + "policy_source_path": [ + dict(item) for item in archive_policy.source_path + ], + } + for message in result.report.messages + for archive in message.archive_evidence + ] + report_json["archive_encryption"] = { + "policy": archive_policy.to_dict(), + "archives": archive_records, + } if resolved_print_outputs_by_index: first_output = next(iter(resolved_print_outputs_by_index.values())) report_json["print_output"] = { diff --git a/src/govoplan_campaign/backend/route_support.py b/src/govoplan_campaign/backend/route_support.py index e5bafe0..0dc2deb 100644 --- a/src/govoplan_campaign/backend/route_support.py +++ b/src/govoplan_campaign/backend/route_support.py @@ -13,6 +13,10 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import ( CAMPAIGN_MAIL_SERVER_KEYS, campaign_mail_profile_id, ) +from govoplan_campaign.backend.archive_encryption import ( + CampaignArchiveEncryptionError, + stamp_legacy_zipcrypto_acknowledgements, +) from govoplan_campaign.backend.db.models import ( Campaign, CampaignIssue, @@ -388,7 +392,9 @@ def _update_campaign_version_detail_response( autosave: bool, audit_action: str, ) -> CampaignVersionDetailResponse: - _get_campaign_for_principal(session, campaign_id, principal, write=True) + campaign = _get_campaign_for_principal( + session, campaign_id, principal, write=True + ) current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) if payload.base_revision is None: error = MissingPreconditionError( @@ -421,9 +427,40 @@ def _update_campaign_version_detail_response( ) from exc if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): _require_permission(principal, "campaigns:recipient:write") + acknowledgements: list[dict[str, Any]] = [] + try: + payload.campaign_json, acknowledgements = ( + stamp_legacy_zipcrypto_acknowledgements( + session, + campaign, + current_version.raw_json + if isinstance(current_version.raw_json, dict) + else {}, + payload.campaign_json, + principal=principal, + ) + ) + except CampaignArchiveEncryptionError as exc: + audit_from_principal( + session, + principal, + action="campaign.archive_encryption_denied", + object_type="campaign_version", + object_id=version_id, + details={"campaign_id": campaign_id, "reason": str(exc)}, + commit=True, + ) + raise HTTPException( + status_code=( + status.HTTP_403_FORBIDDEN + if "Missing scope:" in str(exc) + else status.HTTP_422_UNPROCESSABLE_CONTENT + ), + detail=str(exc), + ) from exc _require_mail_profile_use_if_needed(principal, payload.campaign_json) try: - return _campaign_version_detail_response( + result = _campaign_version_detail_response( session, principal, campaign_id, @@ -462,9 +499,23 @@ def _update_campaign_version_detail_response( } ), "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, + "legacy_zipcrypto_acknowledgements": acknowledgements, }, validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, ) + for acknowledgement in acknowledgements: + audit_from_principal( + session, + principal, + action="campaign.legacy_zipcrypto_acknowledged", + object_type="campaign_version", + object_id=version_id, + details={"campaign_id": campaign_id, **acknowledgement}, + commit=False, + ) + if acknowledgements: + session.commit() + return result except RevisionConflictError as exc: session.rollback() audit_from_principal( diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index a90dee2..4e4dd27 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -130,9 +130,22 @@ from govoplan_campaign.backend.route_support import ( _write_current_version_snapshot_if_available, bounded_query_rows as _bounded_query_rows, ) +from govoplan_campaign.backend.archive_encryption import ( + effective_archive_encryption_policy, +) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + +@router.get("/{campaign_id}/archive-encryption-policy") +def campaign_archive_encryption_policy( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal) + return effective_archive_encryption_policy(session, campaign).to_dict() + CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" diff --git a/src/govoplan_campaign/backend/routes/versions.py b/src/govoplan_campaign/backend/routes/versions.py index 920f0fa..8978780 100644 --- a/src/govoplan_campaign/backend/routes/versions.py +++ b/src/govoplan_campaign/backend/routes/versions.py @@ -770,6 +770,14 @@ def validate_version( except HTTPException: raise except CampaignPersistenceError as exc: + if _is_archive_encryption_denial(exc): + _audit_archive_encryption_denial( + session, principal, version_id=version_id, error=exc + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc @@ -883,6 +891,9 @@ def build_version( "attachment_reuse": _attachment_reuse_audit_evidence( result.get("attachment_reuse") ), + "archive_encryption": _archive_encryption_audit_evidence( + result.get("archive_encryption") + ), }, commit=True, ) @@ -891,6 +902,18 @@ def build_version( include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), ) except CampaignPersistenceError as exc: + if _is_archive_encryption_denial(exc): + _audit_archive_encryption_denial( + session, principal, version_id=version_id, error=exc + ) + raise HTTPException( + status_code=( + status.HTTP_403_FORBIDDEN + if "Missing scope:" in str(exc) + else status.HTTP_422_UNPROCESSABLE_CONTENT + ), + detail=str(exc), + ) from exc raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc @@ -915,6 +938,38 @@ def build_version( ) from exc +def _is_archive_encryption_denial(error: Exception) -> bool: + message = str(error).casefold() + return any( + marker in message + for marker in ( + "archive-encryption", + "archive encryption", + "zipcrypto", + "password-delivery channel", + ) + ) + + +def _audit_archive_encryption_denial( + session: Session, + principal: ApiPrincipal, + *, + version_id: str, + error: Exception, +) -> None: + session.rollback() + audit_from_principal( + session, + principal, + action="campaign.archive_encryption_denied", + object_type="campaign_version", + object_id=version_id, + details={"reason": str(error)}, + commit=True, + ) + + def _residual_file_audit_evidence(value: object) -> dict[str, object]: if not isinstance(value, dict): return {} @@ -945,6 +1000,25 @@ def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]: } +def _archive_encryption_audit_evidence(value: object) -> dict[str, object]: + if not isinstance(value, dict): + return {} + policy = value.get("policy") + archives = [ + item for item in (value.get("archives") or []) if isinstance(item, dict) + ] + return { + "policy_hash": policy.get("policy_hash") + if isinstance(policy, dict) + else None, + "archive_count": len(archives), + "legacy_zipcrypto_count": sum( + 1 for item in archives if item.get("method") == "zip_standard" + ), + "archive_sha256": [item.get("archive_sha256") for item in archives], + } + + def _review_decision_audit_evidence( version: CampaignVersion, ) -> dict[str, object]: diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index c960e01..2189c41 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -1625,6 +1625,44 @@ ], "default": "aes" }, + "password_delivery_channel": { + "type": "string", + "enum": [ + "separate_mail", + "sms", + "letter", + "phone", + "in_person" + ], + "default": "separate_mail" + }, + "legacy_zipcrypto_acknowledged": { + "type": "boolean", + "default": false + }, + "legacy_zipcrypto_reason": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + }, + "legacy_zipcrypto_acknowledged_by": { + "type": [ + "string", + "null" + ], + "maxLength": 255, + "readOnly": true + }, + "legacy_zipcrypto_acknowledged_at": { + "type": [ + "string", + "null" + ], + "maxLength": 80, + "readOnly": true + }, "password_mode": { "type": [ "string", diff --git a/src/govoplan_campaign/backend/sending/execution.py b/src/govoplan_campaign/backend/sending/execution.py index dd7d8cc..118aafb 100644 --- a/src/govoplan_campaign/backend/sending/execution.py +++ b/src/govoplan_campaign/backend/sending/execution.py @@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus +from govoplan_campaign.backend.archive_encryption import ( + CampaignArchiveEncryptionError, + assert_archive_encryption_allowed, +) from govoplan_campaign.backend.campaign.models import ( DeliveryChannelPolicy, DeliveryConfig, @@ -22,8 +26,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import ( from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths -SNAPSHOT_VERSION = "8" -SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION} +SNAPSHOT_VERSION = "9" +SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION} class ExecutionSnapshotError(RuntimeError): @@ -57,6 +61,7 @@ class ExecutionSnapshot(BaseModel): queueable_job_count: int = 0 job_manifest_sha256: str | None = None effective_policy_sha256: str | None = None + archive_encryption: dict[str, Any] | None = None smtp_transport_revision: str | None = None imap_transport_revision: str | None = None uses_mail: bool = True @@ -263,6 +268,7 @@ def create_execution_snapshot( imap_credential_id: str | None = None, jobs: Iterable[CampaignJob] = (), build_summary: dict[str, Any] | None = None, + archive_encryption: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], str]: raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} job_list = list(jobs) @@ -311,6 +317,7 @@ def create_execution_snapshot( delivery, snapshot_version=SNAPSHOT_VERSION, ), + archive_encryption=archive_encryption, smtp_transport_revision=smtp_transport_revision, imap_transport_revision=imap_transport_revision, uses_mail=uses_mail, @@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs( "Revalidate and rebuild the campaign before delivery." ) + campaign = session.get(Campaign, version.campaign_id) + if campaign is None: + raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists") + try: + current_archive_policy = assert_archive_encryption_allowed( + session, + campaign, + raw_json, + ) + except CampaignArchiveEncryptionError as exc: + raise ExecutionSnapshotError(str(exc)) from exc + archive_snapshot = snapshot.archive_encryption + configured_archives = ( + ((raw_json.get("attachments") or {}).get("zip") or {}).get("archives") + if isinstance(raw_json.get("attachments"), dict) + else None + ) + if configured_archives and not isinstance(archive_snapshot, dict): + raise ExecutionSnapshotError( + "Execution snapshot has no governed archive-encryption evidence; rebuild before delivery." + ) + if isinstance(archive_snapshot, dict): + frozen_policy = archive_snapshot.get("policy") + frozen_hash = ( + frozen_policy.get("policy_hash") + if isinstance(frozen_policy, dict) + else None + ) + if frozen_hash != current_archive_policy.policy_hash: + raise ExecutionSnapshotError( + "The effective archive-encryption policy changed after build. Revalidate and rebuild before delivery." + ) + if effect_job is not None: if effect_job.campaign_version_id != version.id: raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version") @@ -494,6 +534,12 @@ def ensure_execution_snapshot( delivery=config.delivery, jobs=jobs, build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, + archive_encryption=( + version.build_summary.get("archive_encryption") + if isinstance(version.build_summary, dict) + and isinstance(version.build_summary.get("archive_encryption"), dict) + else None + ), ) version.execution_snapshot = payload version.execution_snapshot_hash = digest diff --git a/src/govoplan_campaign/backend/services/zip_service.py b/src/govoplan_campaign/backend/services/zip_service.py index 41d06d6..23d42f2 100644 --- a/src/govoplan_campaign/backend/services/zip_service.py +++ b/src/govoplan_campaign/backend/services/zip_service.py @@ -2,6 +2,8 @@ from __future__ import annotations import binascii from datetime import datetime +import hashlib +from importlib import metadata import secrets import stat import struct @@ -49,6 +51,8 @@ def create_zip_archive( output_path.parent.mkdir(parents=True, exist_ok=True) members = _normalized_members(files) if password: + if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}: + raise ValueError(f"Unsupported password-encryption method: {method}") if method == ZIP_METHOD_STANDARD: _create_zipcrypto_archive(output_path, members, password) return output_path @@ -61,6 +65,51 @@ def create_zip_archive( return output_path +def zip_archive_evidence( + output_path: Path, + members: Iterable[Path | ArchiveMember], + *, + password_protected: bool, + method: str, +) -> dict[str, object]: + """Return password-free, content-addressed evidence for one built archive.""" + + normalized = _normalized_members(members) + archive_bytes = output_path.read_bytes() + if password_protected and method == ZIP_METHOD_AES: + try: + implementation_version = metadata.version("pyzipper") + except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer + implementation_version = "unknown" + implementation = "pyzipper" + archive_format = "WinZip AES" + elif password_protected and method == ZIP_METHOD_STANDARD: + implementation = "govoplan-campaign.zipcrypto" + implementation_version = "1" + archive_format = "Legacy ZipCrypto" + else: + implementation = "python.zipfile" + implementation_version = "stdlib" + archive_format = "ZIP (unencrypted)" + return { + "format": archive_format, + "method": method if password_protected else "none", + "password_protected": password_protected, + "implementation": implementation, + "implementation_version": implementation_version, + "archive_sha256": hashlib.sha256(archive_bytes).hexdigest(), + "archive_size_bytes": len(archive_bytes), + "members": [ + { + "name": archive_name, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size_bytes": path.stat().st_size, + } + for path, archive_name in normalized + ], + } + + def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path: """Backward-compatible wrapper for the original per-rule ZIP helper.""" diff --git a/tests/test_archive_encryption_governance.py b/tests/test_archive_encryption_governance.py new file mode 100644 index 0000000..6d94963 --- /dev/null +++ b/tests/test_archive_encryption_governance.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.archive_encryption import ( + CampaignArchiveEncryptionError, + LEGACY_ZIPCRYPTO_SCOPE, + assert_archive_encryption_allowed, + effective_archive_encryption_policy, + stamp_legacy_zipcrypto_acknowledgements, +) +from govoplan_campaign.backend.db.models import Campaign +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.policy import ( + CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION, + CampaignArchiveEncryptionDecision, + PolicySourceStep, +) + + +class _PolicyProvider: + def __init__(self, methods: set[str]) -> None: + self.methods = methods + + def resolve_campaign_archive_encryption(self, session=None, *, request): + del session, request + return CampaignArchiveEncryptionDecision( + allowed_password_encryption_methods=frozenset(self.methods), + allowed_password_delivery_channels=frozenset( + {"separate_mail", "sms", "letter", "phone", "in_person"} + ), + policy_hash="f" * 64, + source_path=( + PolicySourceStep( + scope_type="system", + label="System archive-encryption policy", + applied_fields=("allowed_password_encryption_methods",), + policy={"allowed_password_encryption_methods": sorted(self.methods)}, + ), + ), + ) + + +class _Registry: + def __init__(self, provider) -> None: + self.provider = provider + + def has_capability(self, name: str) -> bool: + return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION + + def capability(self, name: str): + return self.provider if self.has_capability(name) else None + + +class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + self.session = Session(self.engine) + self.campaign = Campaign( + id="campaign-1", + tenant_id="tenant-1", + external_id="example", + name="Example", + owner_user_id="user-1", + ) + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None: + with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None): + policy = effective_archive_encryption_policy(self.session, self.campaign) + self.assertFalse(policy.available) + self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods) + assert_archive_encryption_allowed( + self.session, + self.campaign, + _raw_archive("aes"), + ) + with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"): + assert_archive_encryption_allowed( + self.session, + self.campaign, + _raw_archive("zip_standard", stamped=True), + ) + + def test_existing_password_archive_inherits_separate_mail_channel(self) -> None: + raw = _raw_archive("aes") + raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel") + with patch( + "govoplan_campaign.backend.archive_encryption.get_registry", + return_value=None, + ): + decision = assert_archive_encryption_allowed( + self.session, + self.campaign, + raw, + ) + self.assertIn( + "separate_mail", + decision.allowed_password_delivery_channels, + ) + + def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None: + registry = _Registry(_PolicyProvider({"aes", "zip_standard"})) + candidate = _raw_archive("zip_standard") + candidate["attachments"]["zip"]["archives"][0].update( + { + "legacy_zipcrypto_acknowledged": True, + "legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction", + } + ) + with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry): + with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"): + stamp_legacy_zipcrypto_acknowledgements( + self.session, + self.campaign, + {}, + candidate, + principal=_principal(set()), + ) + stamped, evidence = stamp_legacy_zipcrypto_acknowledgements( + self.session, + self.campaign, + {}, + candidate, + principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}), + ) + archive = stamped["attachments"]["zip"]["archives"][0] + self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"]) + self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"]) + self.assertEqual("f" * 64, evidence[0]["policy_hash"]) + assert_archive_encryption_allowed( + self.session, + self.campaign, + stamped, + principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}), + ) + + +def _raw_archive(method: str, *, stamped: bool = False) -> dict: + archive = { + "id": "archive-1", + "method": method, + "password_enabled": True, + "password_delivery_channel": "separate_mail", + "legacy_zipcrypto_acknowledged": method == "zip_standard", + "legacy_zipcrypto_reason": "Windows recipient compatibility required" + if method == "zip_standard" + else None, + } + if stamped: + archive.update( + { + "legacy_zipcrypto_acknowledged_by": "user-1", + "legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00", + } + ) + return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}} + + +def _principal(scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="user-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="user-1"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_zip_service.py b/tests/test_zip_service.py index a82bfee..ccb461f 100644 --- a/tests/test_zip_service.py +++ b/tests/test_zip_service.py @@ -10,7 +10,10 @@ try: except ImportError: # pragma: no cover pyzipper = None -from govoplan_campaign.backend.services.zip_service import create_zip_archive +from govoplan_campaign.backend.services.zip_service import ( + create_zip_archive, + zip_archive_evidence, +) class ZipServiceTests(unittest.TestCase): @@ -28,6 +31,20 @@ class ZipServiceTests(unittest.TestCase): self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED) self.assertTrue(info.flag_bits & 0x1) self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP") + with self.assertRaises(RuntimeError): + archive.read("message.txt", pwd=b"wrong-password") + + evidence = zip_archive_evidence( + output, + [(source, "message.txt")], + password_protected=True, + method="zip_standard", + ) + self.assertEqual("Legacy ZipCrypto", evidence["format"]) + self.assertEqual("govoplan-campaign.zipcrypto", evidence["implementation"]) + self.assertNotIn("password", evidence) + self.assertEqual(64, len(str(evidence["archive_sha256"]))) + self.assertEqual(64, len(str(evidence["members"][0]["sha256"]))) @unittest.skipIf(pyzipper is None, "pyzipper is not installed") def test_aes_password_zip_keeps_aes_encryption(self) -> None: @@ -63,6 +80,18 @@ class ZipServiceTests(unittest.TestCase): self.assertFalse(info.flag_bits & 0x1) self.assertEqual(archive.read("message.txt"), b"Plain ZIP") + def test_unknown_password_method_fails_without_downgrade_or_output(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source = root / "source.txt" + source.write_text("No downgrade", encoding="utf-8") + output = root / "unknown.zip" + + with self.assertRaisesRegex(ValueError, "Unsupported"): + create_zip_archive(output, [source], "secret", "unknown") + + self.assertFalse(output.exists()) + if __name__ == "__main__": unittest.main() diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 6a4cd43..8480388 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -38,6 +38,24 @@ export type CampaignShare = { export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;}; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];}; +export type CampaignArchiveEncryptionPolicy = { + available: boolean; + allowed_password_encryption_methods: Array<"aes" | "zip_standard">; + allowed_password_delivery_channels: Array<"separate_mail" | "sms" | "letter" | "phone" | "in_person">; + policy_hash: string; + source_path: Array<{ + scope_type: string; + scope_id?: string | null; + path: string; + label: string; + applied_fields: string[]; + policy: Record; + }>; + reason: string; + diagnostics: Array>; + legacy_label: string; +}; + export type CampaignUpdatePayload = { external_id?: string | null; name?: string | null; @@ -1141,6 +1159,16 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr return apiFetch(settings, `/api/v1/campaigns/${campaignId}`); } +export async function getCampaignArchiveEncryptionPolicy( + settings: ApiSettings, + campaignId: string +): Promise { + return apiFetch( + settings, + `/api/v1/campaigns/${encodeURIComponent(campaignId)}/archive-encryption-policy` + ); +} + export async function updateCampaignMetadata( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 53bf446..f8a59c1 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -2,7 +2,11 @@ import { MetricGrid } from "@govoplan/core-webui"; import { useEffect, useMemo, useState } from "react"; import { Pencil } from "lucide-react"; import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; -import type { ApiSettings } from "../../types"; +import type { ApiSettings, AuthInfo } from "../../types"; +import { + getCampaignArchiveEncryptionPolicy, + type CampaignArchiveEncryptionPolicy +} from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui"; @@ -22,14 +26,25 @@ import { updateNested } from "./utils/draftEditor"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; -import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui"; +import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; type PathChooserState = {index: number;}; type IndividualDisableState = {index: number;usageCount: number;}; -export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { +const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = { + available: false, + allowed_password_encryption_methods: ["aes"], + allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"], + policy_hash: "", + source_path: [], + reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.", + diagnostics: [], + legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption" +}; + +export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const navigate = useGuardedNavigate(); const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesFileExplorer = usePlatformUiCapability("files.fileExplorer"); @@ -41,6 +56,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: const [fileSpaces, setFileSpaces] = useState([]); const [individualDisable, setIndividualDisable] = useState(null); const [zipNameEditorIndex, setZipNameEditorIndex] = useState(null); + const [archivePolicy, setArchivePolicy] = useState(UNAVAILABLE_ARCHIVE_POLICY); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({ @@ -70,7 +86,15 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: () => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION, [zipConfig.archives, zipConfig.enabled] ); - const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message; + const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto"); + const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard"); + const legacyConfigurationInvalid = zipConfig.archives.some((archive) => + archive.method === "zip_standard" && ( + !legacyZipCryptoAllowed || !canUseLegacyZipCrypto || + !archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10 + ) + ); + const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid; const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); const attachmentPreviewEntry = useMemo( @@ -94,6 +118,22 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: return () => {cancelled = true;}; }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + useEffect(() => { + let cancelled = false; + setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY); + void getCampaignArchiveEncryptionPolicy(settings, campaignId) + .then((policy) => { if (!cancelled) setArchivePolicy(policy); }) + .catch((cause) => { + if (!cancelled) { + setArchivePolicy({ + ...UNAVAILABLE_ARCHIVE_POLICY, + reason: cause instanceof Error ? cause.message : String(cause) + }); + } + }); + return () => { cancelled = true; }; + }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + function patchBasePaths(paths: AttachmentBasePath[]) { if (locked) return; const normalized = ensureAttachmentBasePaths(paths); @@ -385,6 +425,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: + + {archivePolicy.legacy_label}: {archivePolicy.reason} + {archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.} + {!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.} +
; onEditName: (index: number) => void; passwordFields: ReturnType; + legacyAllowed: boolean; + canUseLegacy: boolean; + allowedDeliveryChannels: CampaignArchiveEncryptionPolicy["allowed_password_delivery_channels"]; patchArchive: (index: number, patch: Partial) => void; setStandard: (index: number) => void; addArchive: (afterIndex?: number) => void; @@ -524,7 +575,7 @@ type ZipArchiveColumnContext = { removeArchive: (index: number) => void; }; -function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn[] { +function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, legacyAllowed, canUseLegacy, allowedDeliveryChannels, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn[] { return [ { id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start", @@ -557,18 +608,65 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, value: (archive) => archive.password_enabled ? "protected" : "none" }, { - id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true, - columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] }, + id: "method", header: "Encryption", width: 360, sortable: true, filterable: true, + columnType: "from-list", list: { options: [{ value: "aes", label: "AES (strong, default)" }, { value: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }] }, render: (archive, index) => - patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />, + aria-label="Archive password encryption" + onChange={(event) => { + const method = event.target.value === "zip_standard" ? "zip_standard" : "aes"; + patchArchive(index, method === "zip_standard" ? { + method, + legacy_zipcrypto_acknowledged: false, + legacy_zipcrypto_reason: "" + } : { + method, + legacy_zipcrypto_acknowledged: false, + legacy_zipcrypto_reason: "" + }); + }}> + + + , value: (archive) => archive.method }, + { + id: "password_delivery_channel", header: "Password delivery", width: 230, sortable: true, filterable: true, + render: (archive, index) => + , + value: (archive) => archive.password_delivery_channel + }, + { + id: "legacy_acknowledgement", header: "Legacy acknowledgement", width: 380, + render: (archive, index) => archive.method === "zip_standard" ? +
+ patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} /> + patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} /> +
: Not required for AES, + value: (archive) => archive.legacy_zipcrypto_reason + }, { id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] }, @@ -685,6 +783,16 @@ function uniqueStrings(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } +function passwordDeliveryChannelLabel(channel: AttachmentZipArchive["password_delivery_channel"]): string { + return { + separate_mail: "Separate email (never this campaign message)", + sms: "SMS", + letter: "Letter", + phone: "Telephone", + in_person: "In person" + }[channel]; +} + type AttachmentSourceColumnContext = { locked: boolean; basePaths: AttachmentBasePath[]; diff --git a/webui/src/features/campaigns/CampaignWorkspace.tsx b/webui/src/features/campaigns/CampaignWorkspace.tsx index 5c1d45d..14d198c 100644 --- a/webui/src/features/campaigns/CampaignWorkspace.tsx +++ b/webui/src/features/campaigns/CampaignWorkspace.tsx @@ -100,7 +100,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/webui/src/features/campaigns/utils/attachments.ts b/webui/src/features/campaigns/utils/attachments.ts index 973f726..4776483 100644 --- a/webui/src/features/campaigns/utils/attachments.ts +++ b/webui/src/features/campaigns/utils/attachments.ts @@ -13,6 +13,11 @@ export type AttachmentZipArchive = { password_field: string; password_scope: AttachmentZipPasswordScope; method: "aes" | "zip_standard"; + password_delivery_channel: "separate_mail" | "sms" | "letter" | "phone" | "in_person"; + legacy_zipcrypto_acknowledged: boolean; + legacy_zipcrypto_reason: string; + legacy_zipcrypto_acknowledged_by?: string; + legacy_zipcrypto_acknowledged_at?: string; // Read-only compatibility values retained when normalizing older campaigns. password_mode?: "none" | "direct" | "field" | "template"; password?: string; @@ -32,7 +37,10 @@ export function createAttachmentZipArchive(name = "attachments.zip", standard = password_enabled: false, password_field: "", password_scope: "local", - method: "aes" + method: "aes", + password_delivery_channel: "separate_mail", + legacy_zipcrypto_acknowledged: false, + legacy_zipcrypto_reason: "" }; } @@ -52,6 +60,11 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC password_field: getText(archive, "password_field"), password_scope: getText(archive, "password_scope") === "global" ? "global" : "local", method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes", + password_delivery_channel: normalizePasswordDeliveryChannel(getText(archive, "password_delivery_channel", "separate_mail")), + legacy_zipcrypto_acknowledged: getBool(archive, "legacy_zipcrypto_acknowledged"), + legacy_zipcrypto_reason: getText(archive, "legacy_zipcrypto_reason"), + ...(getText(archive, "legacy_zipcrypto_acknowledged_by") ? { legacy_zipcrypto_acknowledged_by: getText(archive, "legacy_zipcrypto_acknowledged_by") } : {}), + ...(getText(archive, "legacy_zipcrypto_acknowledged_at") ? { legacy_zipcrypto_acknowledged_at: getText(archive, "legacy_zipcrypto_acknowledged_at") } : {}), ...(legacyMode ? { password_mode: legacyMode } : {}), ...(getText(archive, "password") ? { password: getText(archive, "password") } : {}), ...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {}) @@ -76,6 +89,9 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC password_field: getText(zip, "password_field"), password_scope: "local", method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes", + password_delivery_channel: normalizePasswordDeliveryChannel(getText(zip, "password_delivery_channel", "separate_mail")), + legacy_zipcrypto_acknowledged: getBool(zip, "legacy_zipcrypto_acknowledged"), + legacy_zipcrypto_reason: getText(zip, "legacy_zipcrypto_reason"), ...(legacyMode ? { password_mode: legacyMode } : {}), ...(getText(zip, "password") ? { password: getText(zip, "password") } : {}), ...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {}) @@ -83,6 +99,13 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC }; } +function normalizePasswordDeliveryChannel(value: string): AttachmentZipArchive["password_delivery_channel"] { + if (["sms", "letter", "phone", "in_person"].includes(value)) { + return value as AttachmentZipArchive["password_delivery_channel"]; + } + return "separate_mail"; +} + export function attachmentRuleZipSelection(rule: AttachmentRule): string { const zip = asRecord(rule.zip); const archiveId = getText(zip, "archive_id"); diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css index c677060..1d7be9b 100644 --- a/webui/src/styles/campaign-workspace.css +++ b/webui/src/styles/campaign-workspace.css @@ -2025,6 +2025,16 @@ padding-bottom: 7px; } +.campaign-legacy-zipcrypto-acknowledgement { + display: grid; + gap: var(--space-2); + min-width: 20rem; +} + +.campaign-legacy-zipcrypto-acknowledgement input { + width: 100%; +} + .attachment-zip-name-button { width: 100%; min-height: 36px;