"""C10 ManifestVerifier — takeoff content-hash gate (AZ-324). Read-only validator for the AZ-323-produced cache Manifest. Fail- closed: any deviation in signature, schema, key trust, hashes, or the optional ADR-010 takeoff-origin yields ``outcome=FAIL`` with the union of all ``VerifyFailReason`` values that fired. Never raises on a verify failure — callers branch on ``outcome`` (per the contract at ``_docs/02_document/contracts/c10_provisioning/manifest_verifier.md``). The Protocol + DTOs live alongside the implementation here; the public re-export surface lives in ``c10_provisioning/__init__.py``. Cross-component consumers (C5 takeoff arming, C12 operator tooling) will import via a future ``_types/manifest_verify.py`` shim if and when they wire up — the AZ-270 lint forbids direct ``components.c10_provisioning`` imports from other components. """ from __future__ import annotations import hashlib import logging import math from dataclasses import dataclass from enum import Enum from pathlib import Path, PurePosixPath from typing import Any, Protocol, runtime_checkable from uuid import UUID import orjson from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt from gps_denied_onboard.clock import Clock from gps_denied_onboard.components.c10_provisioning._canonical_hash import ( aggregate_tile_hash, ) from gps_denied_onboard.components.c10_provisioning.manifest_builder import ( TilesByBboxQuery, ) from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar __all__ = [ "ArtifactCheck", "ManifestVerifier", "ManifestVerifierImpl", "VerificationResult", "VerifyFailReason", "VerifyOutcome", ] _VERIFY_LOG_KIND_PREFIX = "c10.manifest.verify" _ED25519_SIG_BYTES = 64 _HASH_CHUNK_BYTES = 64 * 1024 _MANIFEST_FILENAME = "Manifest.json" _SIDECAR_FILENAME = "Manifest.json.sha256" _SIGNATURE_FILENAME = "Manifest.json.sig" class VerifyOutcome(str, Enum): """Top-level pass/fail outcome of :meth:`ManifestVerifier.verify_manifest`.""" PASS = "pass" FAIL = "fail" class VerifyFailReason(str, Enum): """Enumerated reasons a verify failed; multiple may fire per call.""" MANIFEST_NOT_FOUND = "manifest_not_found" SIGNATURE_NOT_FOUND = "signature_not_found" SIGNATURE_INVALID = "signature_invalid" UNTRUSTED_PUBLIC_KEY = "untrusted_public_key" SCHEMA_VIOLATION = "schema_violation" ARTIFACT_MISSING = "artifact_missing" ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch" TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch" MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch" TAKEOFF_ORIGIN_INVALID = "takeoff_origin_invalid" TAKEOFF_ORIGIN_OUT_OF_BBOX = "takeoff_origin_out_of_bbox" @dataclass(frozen=True) class ArtifactCheck: """One Manifest artifact entry's verify outcome.""" relative_path: str expected_sha256: str actual_sha256: str | None # None when the file is missing on disk matched: bool @dataclass(frozen=True) class VerificationResult: """Return value of :meth:`ManifestVerifier.verify_manifest`. ``fail_reasons`` is the deterministic union of every reason that fired during the call; ``fail_details`` is the parallel human- readable diagnostic list. ``takeoff_origin`` is populated for diagnostics even on FAIL whenever the ``flight`` block parsed (MV-INV-9); the callers consume it only on PASS. """ outcome: VerifyOutcome fail_reasons: tuple[VerifyFailReason, ...] fail_details: tuple[str, ...] signing_public_key_fingerprint: str | None per_artifact_checks: tuple[ArtifactCheck, ...] takeoff_origin: LatLonAlt | None flight_id: UUID | None elapsed_ms: int @runtime_checkable class ManifestVerifier(Protocol): """Read-only verifier for a C10-produced ``Manifest.json``. Fail-closed: any deviation yields ``outcome=FAIL``; never raises on a verify failure. Caller passes the trusted operator public- key tuple — this contract does NOT define a key registry. """ def verify_manifest( self, *, manifest_path: Path, trusted_public_keys: tuple[Ed25519PublicKey, ...], ) -> VerificationResult: ... class ManifestVerifierImpl: """Production :class:`ManifestVerifier` implementation (AZ-324). Operator mode (``tile_metadata_store`` supplied) re-derives the aggregate ``tiles_coverage_sha256`` from C6 and flags drift; airborne mode (``None``) trusts the recorded value once the Ed25519 signature passes (per MV-INV-5). """ def __init__( self, *, sidecar: Sha256Sidecar, logger: logging.Logger, clock: Clock, tile_metadata_store: TilesByBboxQuery | None = None, ) -> None: self._sidecar = sidecar self._log = logger self._clock = clock self._tiles = tile_metadata_store def verify_manifest( self, *, manifest_path: Path, trusted_public_keys: tuple[Ed25519PublicKey, ...], ) -> VerificationResult: start_ns = self._clock.monotonic_ns() fail_reasons: list[VerifyFailReason] = [] fail_details: list[str] = [] per_artifact_checks: list[ArtifactCheck] = [] signing_fingerprint: str | None = None takeoff_origin: LatLonAlt | None = None flight_id: UUID | None = None # --- Step A: Manifest exists & sidecar matches ----------------- if not manifest_path.exists(): fail_reasons.append(VerifyFailReason.MANIFEST_NOT_FOUND) fail_details.append(f"Manifest.json not found at {manifest_path}") return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) manifest_bytes = manifest_path.read_bytes() sidecar_path = manifest_path.parent / _SIDECAR_FILENAME if not sidecar_path.exists(): fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION) fail_details.append("missing manifest sidecar at " f"{sidecar_path}") return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) sidecar_value = sidecar_path.read_text(encoding="ascii").strip() actual_self_hash = hashlib.sha256(manifest_bytes).hexdigest() if actual_self_hash != sidecar_value: fail_reasons.append(VerifyFailReason.MANIFEST_SELF_HASH_MISMATCH) fail_details.append( f"Manifest.json sha256={actual_self_hash} != sidecar={sidecar_value}" ) return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) # --- Step B: Signature verifies against a trusted key ---------- signature_path = manifest_path.parent / _SIGNATURE_FILENAME if not signature_path.exists(): fail_reasons.append(VerifyFailReason.SIGNATURE_NOT_FOUND) fail_details.append(f"signature not found at {signature_path}") return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) signature_bytes = signature_path.read_bytes() if len(signature_bytes) != _ED25519_SIG_BYTES: fail_reasons.append(VerifyFailReason.SIGNATURE_INVALID) fail_details.append( f"signature is {len(signature_bytes)} bytes; expected " f"{_ED25519_SIG_BYTES}" ) signing_fingerprint = self._fingerprint_from_body(manifest_bytes) return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) if not trusted_public_keys: fail_reasons.append(VerifyFailReason.UNTRUSTED_PUBLIC_KEY) fail_details.append("trusted_public_keys tuple is empty") signing_fingerprint = self._fingerprint_from_body(manifest_bytes) self._log.error( f"{_VERIFY_LOG_KIND_PREFIX}.untrusted", extra={ "kind": f"{_VERIFY_LOG_KIND_PREFIX}.untrusted", "kv": {"trusted_keys_len": 0}, }, ) return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) signature_ok = False for key in trusted_public_keys: fingerprint = _fingerprint_of(key) try: key.verify(signature_bytes, manifest_bytes) except InvalidSignature: continue signing_fingerprint = fingerprint signature_ok = True break if not signature_ok: body_fingerprint = self._fingerprint_from_body(manifest_bytes) signing_fingerprint = body_fingerprint trusted_fps = {_fingerprint_of(k) for k in trusted_public_keys} if body_fingerprint is not None and body_fingerprint not in trusted_fps: fail_reasons.append(VerifyFailReason.UNTRUSTED_PUBLIC_KEY) fail_details.append( f"signing_public_key_fingerprint={body_fingerprint} not in " f"trusted_public_keys (size={len(trusted_public_keys)})" ) else: fail_reasons.append(VerifyFailReason.SIGNATURE_INVALID) fail_details.append( "Ed25519 signature did not verify against any trusted key" ) return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) # --- Step C: Schema parse ------------------------------------- try: manifest_obj: Any = orjson.loads(manifest_bytes) except orjson.JSONDecodeError as exc: fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION) fail_details.append(f"Manifest.json is not valid JSON: {exc}") return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) schema_violations = _validate_manifest_schema(manifest_obj) if schema_violations: fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION) fail_details.extend(schema_violations) return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) flight_block = manifest_obj.get("flight", {}) or {} flight_id_raw = flight_block.get("flight_id") if flight_id_raw is not None: try: flight_id = UUID(str(flight_id_raw)) except ValueError: fail_reasons.append(VerifyFailReason.SCHEMA_VIOLATION) fail_details.append( f"flight.flight_id is not a valid UUID: {flight_id_raw!r}" ) bbox = _bbox_from_dict(manifest_obj["build"]["bbox"]) origin_block = flight_block.get("takeoff_origin") if origin_block is not None: origin_parsed, origin_errors = _parse_takeoff_origin(origin_block) if origin_parsed is not None: takeoff_origin = origin_parsed if origin_errors: fail_reasons.append(VerifyFailReason.TAKEOFF_ORIGIN_INVALID) fail_details.extend(origin_errors) elif takeoff_origin is not None and not _origin_in_bbox( takeoff_origin, bbox ): fail_reasons.append(VerifyFailReason.TAKEOFF_ORIGIN_OUT_OF_BBOX) fail_details.append( f"takeoff_origin=({takeoff_origin.lat_deg}," f"{takeoff_origin.lon_deg}) outside bbox " f"(min={bbox.min_lat_deg},{bbox.min_lon_deg}; " f"max={bbox.max_lat_deg},{bbox.max_lon_deg})" ) if fail_reasons: return self._fail( fail_reasons, fail_details, per_artifact_checks, signing_fingerprint, takeoff_origin, flight_id, start_ns, ) # --- Step D: Per-artifact hash walk --------------------------- artifacts = manifest_obj["artifacts"] cache_root = manifest_path.parent seen_missing = False seen_mismatch = False for entry in artifacts["engines"]: check = _hash_relative_artifact( cache_root=cache_root, relative=entry["path"], expected=entry["sha256"], ) per_artifact_checks.append(check) if check.actual_sha256 is None: if not seen_missing: fail_reasons.append(VerifyFailReason.ARTIFACT_MISSING) seen_missing = True fail_details.append(f"missing engine artifact: {entry['path']}") elif not check.matched: if not seen_mismatch: fail_reasons.append(VerifyFailReason.ARTIFACT_HASH_MISMATCH) seen_mismatch = True fail_details.append( f"engine hash mismatch: {entry['path']} " f"expected={check.expected_sha256} actual={check.actual_sha256}" ) for label, entry in ( ("descriptor_index", artifacts["descriptor_index"]), ("calibration", artifacts["calibration"]), ): check = _hash_relative_artifact( cache_root=cache_root, relative=entry["path"], expected=entry["sha256"], ) per_artifact_checks.append(check) if check.actual_sha256 is None: if not seen_missing: fail_reasons.append(VerifyFailReason.ARTIFACT_MISSING) seen_missing = True fail_details.append(f"missing {label} artifact: {entry['path']}") elif not check.matched: if not seen_mismatch: fail_reasons.append(VerifyFailReason.ARTIFACT_HASH_MISMATCH) seen_mismatch = True fail_details.append( f"{label} hash mismatch: {entry['path']} " f"expected={check.expected_sha256} actual={check.actual_sha256}" ) tiles_recorded_sha = artifacts["tiles_coverage"]["sha256"] if self._tiles is None: per_artifact_checks.append( ArtifactCheck( relative_path="tiles_coverage", expected_sha256=tiles_recorded_sha, actual_sha256=tiles_recorded_sha, matched=True, ) ) else: try: zoom_levels = tuple( int(z) for z in manifest_obj["build"]["zoom_levels"] ) sector_class = str(manifest_obj["build"]["sector_class"]) records = tuple( self._tiles.query_by_bbox( bbox=bbox, zoom_levels=zoom_levels, sector_class=sector_class, ) ) records = tuple( sorted(records, key=lambda r: (r.zoom, r.lat, r.lon, r.source)) ) computed = aggregate_tile_hash(records) except Exception as exc: per_artifact_checks.append( ArtifactCheck( relative_path="tiles_coverage", expected_sha256=tiles_recorded_sha, actual_sha256=None, matched=False, ) ) fail_reasons.append(VerifyFailReason.TILES_COVERAGE_MISMATCH) fail_details.append( f"tiles_coverage re-derivation failed: {exc}" ) else: matched = computed == tiles_recorded_sha per_artifact_checks.append( ArtifactCheck( relative_path="tiles_coverage", expected_sha256=tiles_recorded_sha, actual_sha256=computed, matched=matched, ) ) if not matched: fail_reasons.append(VerifyFailReason.TILES_COVERAGE_MISMATCH) fail_details.append( f"tiles_coverage drift: recorded={tiles_recorded_sha} " f"computed={computed}" ) elapsed_ms = max( 0, int((self._clock.monotonic_ns() - start_ns) / 1_000_000) ) outcome = VerifyOutcome.PASS if not fail_reasons else VerifyOutcome.FAIL if outcome is VerifyOutcome.PASS: self._log.info( f"{_VERIFY_LOG_KIND_PREFIX}.pass", extra={ "kind": f"{_VERIFY_LOG_KIND_PREFIX}.pass", "kv": { "elapsed_ms": elapsed_ms, "signing_public_key_fingerprint": signing_fingerprint, "n_artifacts": len(per_artifact_checks), "mode": "operator" if self._tiles is not None else "airborne", }, }, ) else: self._log.warning( f"{_VERIFY_LOG_KIND_PREFIX}.fail", extra={ "kind": f"{_VERIFY_LOG_KIND_PREFIX}.fail", "kv": { "elapsed_ms": elapsed_ms, "fail_reasons": [r.value for r in fail_reasons], "n_mismatched": sum( 1 for c in per_artifact_checks if not c.matched ), }, }, ) return VerificationResult( outcome=outcome, fail_reasons=tuple(fail_reasons), fail_details=tuple(fail_details), signing_public_key_fingerprint=signing_fingerprint, per_artifact_checks=tuple(per_artifact_checks), takeoff_origin=takeoff_origin, flight_id=flight_id, elapsed_ms=elapsed_ms, ) def _fail( self, fail_reasons: list[VerifyFailReason], fail_details: list[str], per_artifact_checks: list[ArtifactCheck], signing_fingerprint: str | None, takeoff_origin: LatLonAlt | None, flight_id: UUID | None, start_ns: int, ) -> VerificationResult: elapsed_ms = max( 0, int((self._clock.monotonic_ns() - start_ns) / 1_000_000) ) self._log.warning( f"{_VERIFY_LOG_KIND_PREFIX}.fail", extra={ "kind": f"{_VERIFY_LOG_KIND_PREFIX}.fail", "kv": { "elapsed_ms": elapsed_ms, "fail_reasons": [r.value for r in fail_reasons], "n_mismatched": sum( 1 for c in per_artifact_checks if not c.matched ), }, }, ) return VerificationResult( outcome=VerifyOutcome.FAIL, fail_reasons=tuple(fail_reasons), fail_details=tuple(fail_details), signing_public_key_fingerprint=signing_fingerprint, per_artifact_checks=tuple(per_artifact_checks), takeoff_origin=takeoff_origin, flight_id=flight_id, elapsed_ms=elapsed_ms, ) def _fingerprint_from_body(self, manifest_bytes: bytes) -> str | None: """Best-effort fingerprint lookup for diagnostics on FAIL paths.""" try: obj = orjson.loads(manifest_bytes) except orjson.JSONDecodeError: return None fp = obj.get("signing_public_key_fingerprint") if isinstance(obj, dict) else None if not isinstance(fp, str): return None if len(fp) != 64: return None try: int(fp, 16) except ValueError: return None return fp.lower() def _fingerprint_of(public_key: Ed25519PublicKey) -> str: from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, ) raw = public_key.public_bytes( encoding=Encoding.Raw, format=PublicFormat.Raw, ) return hashlib.sha256(raw).hexdigest() def _validate_manifest_schema(obj: Any) -> list[str]: """Return the list of schema-violation diagnostics; empty when valid.""" errors: list[str] = [] if not isinstance(obj, dict): return ["Manifest top-level is not an object"] for top_key in ("schema_version", "build", "artifacts", "signing_public_key_fingerprint"): if top_key not in obj: errors.append(f"missing required top-level key: {top_key}") if errors: return errors build = obj["build"] if not isinstance(build, dict): errors.append("`build` is not an object") return errors for build_key in ("bbox", "zoom_levels", "sector_class", "built_at", "manifest_hash"): if build_key not in build: errors.append(f"missing required build.{build_key}") bbox = build.get("bbox") if isinstance(bbox, dict): for bbox_key in ("min_lat_deg", "min_lon_deg", "max_lat_deg", "max_lon_deg"): if bbox_key not in bbox: errors.append(f"missing required build.bbox.{bbox_key}") else: errors.append("`build.bbox` is not an object") artifacts = obj["artifacts"] if not isinstance(artifacts, dict): errors.append("`artifacts` is not an object") return errors for sub_key in ("engines", "descriptor_index", "calibration", "tiles_coverage"): if sub_key not in artifacts: errors.append(f"missing required artifacts.{sub_key}") engines = artifacts.get("engines") if isinstance(engines, list): for i, entry in enumerate(engines): errors.extend(_validate_path_sha_entry(entry, f"artifacts.engines[{i}]")) else: errors.append("`artifacts.engines` is not a list") for sub_key in ("descriptor_index", "calibration"): entry = artifacts.get(sub_key) if isinstance(entry, dict): errors.extend(_validate_path_sha_entry(entry, f"artifacts.{sub_key}")) else: errors.append(f"`artifacts.{sub_key}` is not an object") tiles_coverage = artifacts.get("tiles_coverage") if isinstance(tiles_coverage, dict): if not isinstance(tiles_coverage.get("sha256"), str): errors.append("`artifacts.tiles_coverage.sha256` is not a string") if not isinstance(tiles_coverage.get("tile_count"), int): errors.append("`artifacts.tiles_coverage.tile_count` is not an int") else: errors.append("`artifacts.tiles_coverage` is not an object") fp = obj.get("signing_public_key_fingerprint") if not isinstance(fp, str) or len(fp) != 64: errors.append( "`signing_public_key_fingerprint` must be a 64-char hex string" ) return errors def _validate_path_sha_entry(entry: Any, label: str) -> list[str]: if not isinstance(entry, dict): return [f"{label} is not an object"] errors: list[str] = [] raw_path = entry.get("path") sha = entry.get("sha256") if not isinstance(raw_path, str): errors.append(f"{label}.path is not a string") else: if raw_path.startswith("/"): errors.append(f"{label}.path must be relative; got absolute {raw_path!r}") parts = PurePosixPath(raw_path).parts if ".." in parts: errors.append( f"{label}.path must not contain `..` segments; got {raw_path!r}" ) if not isinstance(sha, str) or len(sha) != 64: errors.append(f"{label}.sha256 must be a 64-char hex string") return errors def _bbox_from_dict(bbox: dict[str, float]) -> BoundingBox: return BoundingBox( min_lat_deg=float(bbox["min_lat_deg"]), min_lon_deg=float(bbox["min_lon_deg"]), max_lat_deg=float(bbox["max_lat_deg"]), max_lon_deg=float(bbox["max_lon_deg"]), ) def _parse_takeoff_origin(block: Any) -> tuple[LatLonAlt | None, list[str]]: errors: list[str] = [] if not isinstance(block, dict): errors.append("`flight.takeoff_origin` is not an object") return None, errors lat = block.get("lat_deg") lon = block.get("lon_deg") alt = block.get("alt_m") if not isinstance(lat, (int, float)) or isinstance(lat, bool): errors.append("`flight.takeoff_origin.lat_deg` is not a number") if not isinstance(lon, (int, float)) or isinstance(lon, bool): errors.append("`flight.takeoff_origin.lon_deg` is not a number") if not isinstance(alt, (int, float)) or isinstance(alt, bool): errors.append("`flight.takeoff_origin.alt_m` is not a number") if errors: return None, errors lat_f = float(lat) # type: ignore[arg-type] lon_f = float(lon) # type: ignore[arg-type] alt_f = float(alt) # type: ignore[arg-type] parsed = LatLonAlt(lat_deg=lat_f, lon_deg=lon_f, alt_m=alt_f) if not (-90.0 <= lat_f <= 90.0): errors.append(f"flight.takeoff_origin.lat_deg={lat_f} out of [-90, 90]") if not (-180.0 <= lon_f <= 180.0): errors.append( f"flight.takeoff_origin.lon_deg={lon_f} out of [-180, 180]" ) if not math.isfinite(alt_f): errors.append("flight.takeoff_origin.alt_m must be finite") return parsed, errors def _origin_in_bbox(origin: LatLonAlt, bbox: BoundingBox) -> bool: return bbox.contains(origin.lat_deg, origin.lon_deg) def _hash_relative_artifact( *, cache_root: Path, relative: str, expected: str, ) -> ArtifactCheck: target = cache_root / relative if not target.exists(): return ArtifactCheck( relative_path=relative, expected_sha256=expected, actual_sha256=None, matched=False, ) hasher = hashlib.sha256() with target.open("rb") as fh: while True: chunk = fh.read(_HASH_CHUNK_BYTES) if not chunk: break hasher.update(chunk) actual = hasher.hexdigest() return ArtifactCheck( relative_path=relative, expected_sha256=expected, actual_sha256=actual, matched=actual == expected, )