"""Issuance of role-scoped receipts from same-process installed observations.""" from __future__ import annotations import base64 from datetime import UTC, datetime from pathlib import Path from typing import Any, Mapping from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from .capability_fit import canonical_hash from .evidence import ( MAX_LOCAL_OBSERVATION_AGE, OPAQUE_ID_PATTERN, _catalog_artifact_identities, _record_integrity_semantics_valid, canonical_bytes, canonical_sha256, normalize_package_name, ) from govoplan_release.artifact_identity import inspect_python_wheel def issue_installer_receipt( *, assessment: Mapping[str, Any], catalog: Mapping[str, Any], installed_evidence: dict[str, Any], receipt_id: str, key_id: str, private_key: Ed25519PrivateKey, consumed_artifacts: Mapping[str, Path | str] | None = None, issued_at: datetime | None = None, same_process_observation: bool = False, ) -> dict[str, Any]: """Sign a receipt only for a fresh observation collected by this process. File-based evidence is intentionally not an admitted issuance input. The CLI collector calls this function directly with its in-memory observation. """ if same_process_observation is not True: raise ValueError("installer receipts require a same-process observation") if OPAQUE_ID_PATTERN.fullmatch(receipt_id) is None: raise ValueError("receipt ID must be a bounded opaque ID") if OPAQUE_ID_PATTERN.fullmatch(key_id) is None: raise ValueError("installer signing key ID must be a bounded opaque ID") if not isinstance(consumed_artifacts, Mapping) or not consumed_artifacts: raise ValueError("installer receipt issuance requires exact consumed wheels") release = catalog.get("release") raw_artifacts = release.get("artifacts") if isinstance(release, Mapping) else None catalog_artifacts, artifact_findings = _catalog_artifact_identities(raw_artifacts) if artifact_findings: raise ValueError("signed catalog artifact manifest is invalid") evidence_rows = installed_evidence.get("artifacts") if not isinstance(evidence_rows, list) or not evidence_rows: raise ValueError("installed evidence has no artifact observations") expected_packages = _expected_packages(assessment=assessment, catalog=catalog) receipt_rows: list[dict[str, Any]] = [] seen: set[str] = set() for artifact in evidence_rows: if not isinstance(artifact, dict): raise ValueError("installed evidence contains a malformed artifact") package_name = normalize_package_name(str(artifact.get("package_name") or "")) package_version = str(artifact.get("package_version") or "") if package_name in seen: raise ValueError("installed evidence contains duplicate packages") seen.add(package_name) catalog_artifact = catalog_artifacts.get(package_name) record = artifact.get("record_integrity") installed_payload = ( record.get("installed_payload_identity") if isinstance(record, Mapping) else None ) if ( catalog_artifact is None or catalog_artifact.get("package_version") != package_version or not isinstance(record, Mapping) or record.get("status") != "verified" or not _record_integrity_semantics_valid(record) or not isinstance(installed_payload, Mapping) ): raise ValueError( "installed payload is not a verified match for a catalog artifact" ) artifact_path = consumed_artifacts.get(package_name) if artifact_path is None: raise ValueError("every installed package requires its exact consumed wheel") consumed = inspect_python_wheel(artifact_path) if ( consumed.package_name != package_name or consumed.package_version != package_version or consumed.archive_sha256 != catalog_artifact.get("archive_sha256") or consumed.catalog_payload().get("installed_payload") != catalog_artifact.get("installed_payload") or record.get("artifact_payload_identity") != catalog_artifact.get("installed_payload") ): raise ValueError( "consumed wheel or observed wheel payload differs from the signed catalog identity" ) receipt_rows.append( { "package_name": package_name, "package_version": package_version, "catalog_archive_sha256": catalog_artifact["archive_sha256"], "installed_payload": dict(installed_payload), } ) if seen != expected_packages: raise ValueError( "installed evidence must contain exactly the enabled assessed catalog packages" ) if set(consumed_artifacts) != expected_packages: raise ValueError("consumed wheel set must exactly match enabled packages") receipt_rows.sort(key=lambda item: (item["package_name"], item["package_version"])) observed_at = issued_at or datetime.now(tz=UTC) if observed_at.tzinfo is None: raise ValueError("installer receipt issuance time must include a timezone") collected_at = _datetime(installed_evidence.get("collected_at")) if ( collected_at is None or observed_at < collected_at or observed_at - collected_at > MAX_LOCAL_OBSERVATION_AGE ): raise ValueError( "installer receipt issuance must follow live collection within five minutes" ) assessment_release = assessment.get("release") assessment_release_ref = ( assessment_release.get("ref") if isinstance(assessment_release, Mapping) else None ) receipt: dict[str, Any] = { "$schema": "./installer-receipt.schema.json", "schema_version": "0.1.0", "evidence_kind": "govoplan.installer-receipt", "receipt_id": receipt_id, "assessment_id": assessment.get("assessment_id"), "assessment_release": assessment_release_ref, "installed_evidence_sha256": canonical_sha256(installed_evidence), "catalog": { "channel": catalog.get("channel"), "sequence": catalog.get("sequence"), "sha256": canonical_hash(catalog), }, "issued_at": observed_at.astimezone(UTC).isoformat().replace("+00:00", "Z"), "artifacts": receipt_rows, } receipt["signatures"] = [ { "algorithm": "ed25519", "key_id": key_id, "value": base64.b64encode( private_key.sign(canonical_bytes(receipt)) ).decode("ascii"), } ] return receipt def _expected_packages( *, assessment: Mapping[str, Any], catalog: Mapping[str, Any] ) -> set[str]: entries: dict[str, Mapping[str, Any]] = {} core = catalog.get("core_release") if isinstance(core, Mapping): entries["core"] = core modules = catalog.get("modules") if isinstance(modules, list): for entry in modules: if isinstance(entry, Mapping) and isinstance(entry.get("module_id"), str): entries[str(entry["module_id"])] = entry expected: set[str] = set() composition = assessment.get("composition") if not isinstance(composition, list): raise ValueError("assessment composition is unavailable") for component in composition: if not isinstance(component, Mapping) or component.get("enabled") is not True: continue entry = entries.get(str(component.get("module_id") or "")) package = entry.get("python_package") if isinstance(entry, Mapping) else None if not isinstance(package, str): raise ValueError("enabled assessment component has no catalog package") normalized = normalize_package_name(package) if normalized in expected: raise ValueError("enabled assessment packages are ambiguous") expected.add(normalized) if not expected: raise ValueError("assessment has no enabled catalog packages") return expected def _datetime(value: object) -> datetime | None: if not isinstance(value, str): return None try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None return parsed.astimezone(UTC) if parsed.tzinfo is not None else None