"""C10 ManifestBuilder + Ed25519ManifestSigner (AZ-323). Produces the signed cache Manifest covering every shipped artifact plus the build-identity tuple whose canonical hash (``manifest_hash``) is the D-C10-1 idempotence key. Implements the AC-NEW-1 trust chain (takeoff arming refuses to deserialize engines before a Manifest verify succeeds — AZ-324 owns the verify; this task owns the build). Cross-component DTOs (``LatLonAlt``, ``BoundingBox``) come from ``_types/geo.py``; engine entries from ``_types/inference.py``; the ``Manifest`` placeholder DTO from ``_types/manifests.py``. No direct ``components.X`` imports — the AZ-507 lint forbids it. """ from __future__ import annotations import hashlib import logging import time from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path from typing import Protocol, runtime_checkable from uuid import UUID import orjson from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) from cryptography.hazmat.primitives.serialization import load_pem_private_key from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt from gps_denied_onboard._types.inference import EngineCacheEntry from gps_denied_onboard.clock import Clock from gps_denied_onboard.components.c10_provisioning._canonical_hash import ( TileHashRecord, aggregate_tile_hash, compute_manifest_hash, ) from gps_denied_onboard.components.c10_provisioning.config import ( C10ManifestConfig, SigningMode, ) from gps_denied_onboard.components.c10_provisioning.errors import ( ManifestWriteError, ) from gps_denied_onboard.components.c10_provisioning.interface import ( ManifestSigner, SigningKeyHandle, ) from gps_denied_onboard.helpers.sha256_sidecar import ( Sha256Sidecar, Sha256SidecarError, ) __all__ = [ "VALID_SECTOR_CLASSES", "Ed25519ManifestSigner", "ManifestArtifact", "ManifestBuildInput", "ManifestBuilder", "TilesByBboxQuery", ] _BUILD_LOG_KIND_PREFIX = "c10.manifest" _MANIFEST_FILENAME = "Manifest.json" _SIGNATURE_FILENAME = "Manifest.json.sig" _ED25519_PUBKEY_BYTES = 32 _ED25519_SIG_BYTES = 64 VALID_SECTOR_CLASSES: frozenset[str] = frozenset( {"active_conflict", "stable_rear"} ) @runtime_checkable class TilesByBboxQuery(Protocol): """Consumer-side structural cut over C6's ``TileMetadataStore``. The composition root adapts :class:`gps_denied_onboard.components.c6_tile_cache.TileMetadataStore` by translating its ``query_by_bbox`` return value into a tuple of :class:`TileHashRecord`. C10 depends on THIS Protocol so ``components/c10_provisioning/*`` never imports ``components.c6_*`` (AZ-270 + AZ-507 boundary). """ def query_by_bbox( self, *, bbox: BoundingBox, zoom_levels: tuple[int, ...], sector_class: str, ) -> Iterable[TileHashRecord]: ... @dataclass(frozen=True) class ManifestBuildInput: """Frozen call argument for :meth:`ManifestBuilder.build_manifest`. Per the AZ-323 spec ``sector_class`` is the c6 enum's ``.value`` string ('active_conflict' / 'stable_rear'); the composition root translates the C6 enum to its string form before injecting so C10 stays free of the C6 import. ``takeoff_origin`` + ``flight_id`` are the ADR-010 / AZ-489 pass-through fields — when supplied they are both baked into the Manifest body AND fed into the ``manifest_hash`` so a re-planned flight produces a fresh cache identity (AC-15 / AC-16). """ cache_root: Path bbox: BoundingBox zoom_levels: tuple[int, ...] sector_class: str engine_entries: tuple[EngineCacheEntry, ...] descriptor_index_path: Path calibration_path: Path key_path: Path takeoff_origin: LatLonAlt | None = None flight_id: UUID | None = None @dataclass(frozen=True) class ManifestArtifact: """Return value of :meth:`ManifestBuilder.build_manifest`. ``manifest_hash`` is the D-C10-1 idempotence key — derived from the build identity tuple, NOT from the Manifest file bytes (which include ``built_at`` and so differ across runs). The Manifest file's own SHA-256 lives on disk as ``Manifest.json.sha256`` per AC-11. """ manifest_path: Path signature_path: Path manifest_hash: str signing_public_key_fingerprint: str total_artifacts_listed: int @dataclass(frozen=True) class _Ed25519SigningKey(SigningKeyHandle): """Opaque handle wrapping a ``cryptography`` Ed25519 private key. Frozen so callers cannot mutate the key in flight; the underlying ``Ed25519PrivateKey`` object stays in the dataclass field but is not exposed by name on :class:`SigningKeyHandle`. """ private_key: Ed25519PrivateKey = field(repr=False) public_key_raw: bytes fingerprint_hex: str class Ed25519ManifestSigner: """Default :class:`ManifestSigner` impl backed by ``cryptography``. Loads PEM-encoded PKCS8 Ed25519 private keys per AZ-323 Risk 4 — other formats (OpenSSH, raw 32-byte) raise :class:`ManifestWriteError` with the underlying ``cryptography`` exception chained via ``__cause__`` (AC-9). """ def load_signing_key(self, key_path: Path) -> SigningKeyHandle: try: pem_bytes = key_path.read_bytes() except (OSError, FileNotFoundError) as exc: raise ManifestWriteError( f"operator signing key load failed: cannot read {key_path}: {exc}" ) from exc try: private_key = load_pem_private_key(pem_bytes, password=None) except (ValueError, TypeError, UnsupportedAlgorithm) as exc: raise ManifestWriteError( f"operator signing key load failed: malformed PEM at {key_path}: {exc}" ) from exc if not isinstance(private_key, Ed25519PrivateKey): raise ManifestWriteError( "operator signing key load failed: not an Ed25519 private key " f"(got {type(private_key).__name__}); AZ-323 supports Ed25519 only" ) public_key = private_key.public_key() public_raw = _ed25519_public_raw(public_key) return _Ed25519SigningKey( private_key=private_key, public_key_raw=public_raw, fingerprint_hex=hashlib.sha256(public_raw).hexdigest(), ) def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes: handle = _require_ed25519_handle(key) signature = handle.private_key.sign(payload_bytes) # Defensive: Ed25519 signatures are always 64 bytes — fail fast on # a library upgrade that changes the contract. if len(signature) != _ED25519_SIG_BYTES: raise ManifestWriteError( f"Ed25519 signer produced {len(signature)} bytes; expected " f"{_ED25519_SIG_BYTES}" ) return signature def public_key_fingerprint(self, key: SigningKeyHandle) -> str: return _require_ed25519_handle(key).fingerprint_hex def _ed25519_public_raw(public_key: Ed25519PublicKey) -> bytes: from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, ) raw = public_key.public_bytes( encoding=Encoding.Raw, format=PublicFormat.Raw, ) if len(raw) != _ED25519_PUBKEY_BYTES: raise ManifestWriteError( f"Ed25519 public key has unexpected length: {len(raw)} != " f"{_ED25519_PUBKEY_BYTES}" ) return raw def _require_ed25519_handle(key: SigningKeyHandle) -> _Ed25519SigningKey: if not isinstance(key, _Ed25519SigningKey): raise ManifestWriteError( "Ed25519ManifestSigner received a foreign SigningKeyHandle " f"({type(key).__name__}); only handles produced by load_signing_key " "are accepted" ) return key class ManifestBuilder: """Build a signed cache Manifest at ``cache_root/Manifest.json``. Atomic-write contract: Manifest body + ``.sha256`` sidecar + ``.sig`` are all written via :class:`Sha256Sidecar.write_atomic*`, so a kill mid-build leaves either the previous-good triple or the new triple — never a partial Manifest (AC-10). """ def __init__( self, *, sidecar: Sha256Sidecar, signer: ManifestSigner, tile_metadata_store: TilesByBboxQuery, logger: logging.Logger, clock: Clock, config: C10ManifestConfig, ) -> None: self._sidecar = sidecar self._signer = signer self._tiles = tile_metadata_store self._log = logger self._clock = clock self._config = config def build_manifest(self, request: ManifestBuildInput) -> ManifestArtifact: self._validate_request(request) key = self._load_and_gate_key(request.key_path) fingerprint = self._signer.public_key_fingerprint(key) self._gate_operator_mode(fingerprint) calibration_sha256 = self._sha256_file(request.calibration_path) descriptor_index_sha256 = self._read_descriptor_index_sidecar( request.descriptor_index_path ) sorted_tiles = self._fetch_sorted_tiles( bbox=request.bbox, zoom_levels=request.zoom_levels, sector_class=request.sector_class, ) tiles_coverage_sha256 = aggregate_tile_hash(sorted_tiles) engine_artifacts = tuple( { "path": str(entry.engine_path), "sha256": entry.sha256_hex, } for entry in request.engine_entries ) manifest_hash = compute_manifest_hash( engine_entries=request.engine_entries, calibration_sha256=calibration_sha256, descriptor_index_sha256=descriptor_index_sha256, tiles_coverage_sha256=tiles_coverage_sha256, sector_class=request.sector_class, bbox=request.bbox, zoom_levels=request.zoom_levels, takeoff_origin=request.takeoff_origin, flight_id=request.flight_id, ) built_at_iso = _ns_to_iso_utc(self._clock.time_ns()) manifest_body = self._assemble_manifest_dict( schema_version=self._config.schema_version, bbox=request.bbox, zoom_levels=request.zoom_levels, sector_class=request.sector_class, built_at_iso=built_at_iso, manifest_hash=manifest_hash, flight_id=request.flight_id, takeoff_origin=request.takeoff_origin, engine_artifacts=engine_artifacts, descriptor_index_path=request.descriptor_index_path, descriptor_index_sha256=descriptor_index_sha256, calibration_path=request.calibration_path, calibration_sha256=calibration_sha256, tiles_coverage_sha256=tiles_coverage_sha256, tiles_count=len(sorted_tiles), fingerprint=fingerprint, ) canonical_bytes = _canonical_json_with_trailing_newline(manifest_body) manifest_path = request.cache_root / _MANIFEST_FILENAME signature_path = request.cache_root / _SIGNATURE_FILENAME request.cache_root.mkdir(parents=True, exist_ok=True) self._atomic_write_manifest(manifest_path, canonical_bytes) signature_bytes = self._signer.sign(key, canonical_bytes) self._atomic_write_signature(signature_path, signature_bytes) total_artifacts = len(engine_artifacts) + 3 # descriptor_index + calibration + tiles_coverage self._log.info( f"{_BUILD_LOG_KIND_PREFIX}.build.success", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.build.success", "kv": { "manifest_hash": manifest_hash, "total_artifacts_listed": total_artifacts, "signing_public_key_fingerprint": fingerprint, "tiles_count": len(sorted_tiles), "schema_version": self._config.schema_version, }, }, ) return ManifestArtifact( manifest_path=manifest_path, signature_path=signature_path, manifest_hash=manifest_hash, signing_public_key_fingerprint=fingerprint, total_artifacts_listed=total_artifacts, ) def _validate_request(self, request: ManifestBuildInput) -> None: if request.sector_class not in VALID_SECTOR_CLASSES: raise ManifestWriteError( f"sector_class={request.sector_class!r} not in " f"{sorted(VALID_SECTOR_CLASSES)}" ) if not request.zoom_levels: raise ManifestWriteError( "zoom_levels must be a non-empty tuple of ints" ) if request.takeoff_origin is not None: origin = request.takeoff_origin if not (-90.0 <= origin.lat_deg <= 90.0): raise ManifestWriteError( f"takeoff_origin.lat_deg={origin.lat_deg} out of [-90, 90]" ) if not (-180.0 <= origin.lon_deg <= 180.0): raise ManifestWriteError( f"takeoff_origin.lon_deg={origin.lon_deg} out of [-180, 180]" ) def _load_and_gate_key(self, key_path: Path) -> SigningKeyHandle: try: return self._signer.load_signing_key(key_path) except ManifestWriteError: # Already logged at the call site below; the signer raises with # an actionable diagnostic. We must still emit the ERROR record # so operators see a single structured "build.error" entry. self._log.error( f"{_BUILD_LOG_KIND_PREFIX}.build.error", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error", "kv": { "phase": "load_signing_key", "key_path": str(key_path), }, }, ) raise def _gate_operator_mode(self, fingerprint: str) -> None: allowlist = self._config.allowed_operator_fingerprints if self._config.signing_mode is SigningMode.OPERATOR: if fingerprint not in allowlist: self._log.error( f"{_BUILD_LOG_KIND_PREFIX}.build.error", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error", "kv": { "phase": "operator_mode_gate", "offered_fingerprint": fingerprint, "allowed_fingerprints": list(allowlist), }, }, ) raise ManifestWriteError( "signing key fingerprint not in allowed_operator_fingerprints: " f"offered={fingerprint!r}, allowed={sorted(allowlist)!r}" ) elif self._config.signing_mode is SigningMode.DEV: if fingerprint in allowlist: self._log.warning( f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key", "kv": { "offered_fingerprint": fingerprint, }, }, ) def _sha256_file(self, path: Path) -> str: try: return hashlib.sha256(path.read_bytes()).hexdigest() except (OSError, FileNotFoundError) as exc: raise ManifestWriteError( f"manifest build: cannot hash artifact at {path}: {exc}" ) from exc def _read_descriptor_index_sidecar(self, descriptor_index_path: Path) -> str: sidecar_path = Path(str(descriptor_index_path) + ".sha256") try: text = sidecar_path.read_text(encoding="ascii").strip() except (OSError, FileNotFoundError) as exc: raise ManifestWriteError( "manifest build: descriptor_index sidecar missing at " f"{sidecar_path}: {exc}" ) from exc if len(text) != 64: raise ManifestWriteError( "manifest build: descriptor_index sidecar at " f"{sidecar_path} is not 64 hex chars (got {len(text)})" ) try: int(text, 16) except ValueError as exc: raise ManifestWriteError( "manifest build: descriptor_index sidecar at " f"{sidecar_path} is not hex: {exc}" ) from exc if text.lower() != text: raise ManifestWriteError( "manifest build: descriptor_index sidecar at " f"{sidecar_path} must be lowercase hex" ) return text def _fetch_sorted_tiles( self, *, bbox: BoundingBox, zoom_levels: tuple[int, ...], sector_class: str, ) -> tuple[TileHashRecord, ...]: raw = tuple( self._tiles.query_by_bbox( bbox=bbox, zoom_levels=zoom_levels, sector_class=sector_class, ) ) return tuple( sorted(raw, key=lambda r: (r.zoom, r.lat, r.lon, r.source)) ) def _assemble_manifest_dict( self, *, schema_version: str, bbox: BoundingBox, zoom_levels: tuple[int, ...], sector_class: str, built_at_iso: str, manifest_hash: str, flight_id: UUID | None, takeoff_origin: LatLonAlt | None, engine_artifacts: tuple[dict[str, str], ...], descriptor_index_path: Path, descriptor_index_sha256: str, calibration_path: Path, calibration_sha256: str, tiles_coverage_sha256: str, tiles_count: int, fingerprint: str, ) -> dict[str, object]: flight_block: dict[str, object] = { "flight_id": str(flight_id) if flight_id is not None else None, } if takeoff_origin is not None: flight_block["takeoff_origin"] = { "lat_deg": takeoff_origin.lat_deg, "lon_deg": takeoff_origin.lon_deg, "alt_m": takeoff_origin.alt_m, } return { "schema_version": schema_version, "build": { "bbox": { "min_lat_deg": bbox.min_lat_deg, "min_lon_deg": bbox.min_lon_deg, "max_lat_deg": bbox.max_lat_deg, "max_lon_deg": bbox.max_lon_deg, }, "zoom_levels": list(zoom_levels), "sector_class": sector_class, "built_at": built_at_iso, "manifest_hash": manifest_hash, }, "flight": flight_block, "artifacts": { "engines": [dict(e) for e in engine_artifacts], "descriptor_index": { "path": str(descriptor_index_path), "sha256": descriptor_index_sha256, }, "calibration": { "path": str(calibration_path), "sha256": calibration_sha256, }, "tiles_coverage": { "sha256": tiles_coverage_sha256, "tile_count": tiles_count, }, }, "signing_public_key_fingerprint": fingerprint, } def _atomic_write_manifest(self, path: Path, payload: bytes) -> None: try: self._sidecar.write_atomic_and_sidecar(path, payload) except Sha256SidecarError as exc: self._log.error( f"{_BUILD_LOG_KIND_PREFIX}.build.error", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error", "kv": {"phase": "write_manifest", "path": str(path)}, }, ) raise ManifestWriteError( f"manifest build: atomic write failed at {path}: {exc}" ) from exc def _atomic_write_signature(self, path: Path, payload: bytes) -> None: try: self._sidecar.write_atomic(path, payload) except Sha256SidecarError as exc: self._log.error( f"{_BUILD_LOG_KIND_PREFIX}.build.error", extra={ "kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error", "kv": {"phase": "write_signature", "path": str(path)}, }, ) raise ManifestWriteError( f"manifest build: atomic write failed at {path}: {exc}" ) from exc def _canonical_json_with_trailing_newline(payload: dict[str, object]) -> bytes: body = orjson.dumps( payload, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2, ) if not body.endswith(b"\n"): body += b"\n" return body def _ns_to_iso_utc(time_ns: int) -> str: """Format ns-since-epoch as RFC 3339 UTC with second precision. Second precision suffices for ``built_at`` — operators inspect the Manifest at hour / minute granularity, and the build-identity hash deliberately excludes ``built_at`` so the AC-2 byte-for-byte determinism check works only by redacting this exact field. """ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time_ns / 1_000_000_000))