205 lines
8.4 KiB
Python
205 lines
8.4 KiB
Python
"""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
|