[AZ-507] [AZ-323] [AZ-324] C10 Manifest build + verify + AZ-270 hygiene

AZ-507: codify cross-component import rule. Added
_types/inference_errors.py shim re-exporting EngineBuildError +
CalibrationCacheError from c7_inference; narrowed C10
EngineCompiler's except Exception to the two typed errors so unknown
exceptions propagate (AC-3). Rewrote module-layout.md "Imports from"
sections for 9 components + added Rule 9; appended an
architecture.md ADR-009 note explaining why components must go
through _types/*.

AZ-323: ManifestBuilder + Ed25519ManifestSigner. Canonical JSON via
orjson OPT_SORT_KEYS+OPT_INDENT_2, atomic-write Manifest.json + sha
sidecar + .sig via AZ-280, operator-key fingerprint allowlist gate
(C10-ST-01), ADR-010 takeoff_origin + flight_id baked into Manifest
AND manifest_hash so re-planned routes change the cache identity
(AC-15/AC-16). 20 unit tests cover all 16 ACs.

AZ-324: ManifestVerifierImpl. Fail-closed Steps A-D: Manifest.json
sidecar self-hash, Ed25519 trust-key set, schema parse with
absolute/.. path rejection + takeoff_origin in-bbox check, stream
SHA-256 per artifact with multi-failure accumulation. Operator mode
re-derives tiles_coverage_sha256 from C6; airborne mode trusts the
signed aggregate. 19 unit tests cover all 17 ACs.

Composition root: c10_factory.build_manifest_builder +
build_manifest_verifier + c6_tile_metadata_store_to_tiles_query
adapter (the one place that legitimately imports both C6 and C10
without violating the AZ-270 lint).

Dependency: pinned cryptography>=43.0,<46.0 in pyproject.toml.

Tests: 1300 passed, 80 skipped (env-only), ruff clean for all
AZ-323/324 files.

AZ-306 (FAISS) intentionally deferred to batch 35 — needs C++
pybind11 toolchain not present in this environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 02:37:14 +03:00
parent 6ca8d78190
commit e2bebefdfc
20 changed files with 3406 additions and 26 deletions
@@ -13,35 +13,79 @@ from gps_denied_onboard._types.inference import EngineCacheEntry
from gps_denied_onboard._types.manifests import Manifest
from gps_denied_onboard.components.c10_provisioning.config import (
BackboneConfig,
C10ManifestConfig,
C10ProvisioningConfig,
SigningMode,
)
from gps_denied_onboard.components.c10_provisioning.engine_compiler import (
BackboneSpec,
CompileEngineCallable,
CompileOutcome,
EngineCompiler,
EngineCompileRequest,
EngineCompileResult,
EngineCompileSummary,
EngineCompiler,
)
from gps_denied_onboard.components.c10_provisioning.errors import (
C10ProvisioningError,
ManifestWriteError,
)
from gps_denied_onboard.components.c10_provisioning.interface import (
CacheProvisioner,
ManifestSigner,
SigningKeyHandle,
)
from gps_denied_onboard.components.c10_provisioning.manifest_builder import (
VALID_SECTOR_CLASSES,
Ed25519ManifestSigner,
ManifestArtifact,
ManifestBuilder,
ManifestBuildInput,
TileHashRecord,
TilesByBboxQuery,
)
from gps_denied_onboard.components.c10_provisioning.manifest_verifier import (
ArtifactCheck,
ManifestVerifier,
ManifestVerifierImpl,
VerificationResult,
VerifyFailReason,
VerifyOutcome,
)
from gps_denied_onboard.config.schema import register_component_block
register_component_block("c10_provisioning", C10ProvisioningConfig)
__all__ = [
"VALID_SECTOR_CLASSES",
"ArtifactCheck",
"BackboneConfig",
"BackboneSpec",
"C10ManifestConfig",
"C10ProvisioningConfig",
"C10ProvisioningError",
"CacheProvisioner",
"CompileEngineCallable",
"CompileOutcome",
"Ed25519ManifestSigner",
"EngineCacheEntry",
"EngineCompileRequest",
"EngineCompileResult",
"EngineCompileSummary",
"EngineCompiler",
"Manifest",
"ManifestArtifact",
"ManifestBuildInput",
"ManifestBuilder",
"ManifestSigner",
"ManifestVerifier",
"ManifestVerifierImpl",
"ManifestWriteError",
"SigningKeyHandle",
"SigningMode",
"TileHashRecord",
"TilesByBboxQuery",
"VerificationResult",
"VerifyFailReason",
"VerifyOutcome",
]
@@ -1,4 +1,4 @@
"""C10 cache-provisioning config block (AZ-321).
"""C10 cache-provisioning config block (AZ-321, extended by AZ-323).
Registered into ``config.components['c10_provisioning']`` by the
package ``__init__.py``. The composition-root factory
@@ -7,6 +7,10 @@ reads this block to enumerate the project's backbones and to bound
the workspace memory passed to
:meth:`InferenceRuntime.compile_engine`.
AZ-323 extends the block with a nested :class:`C10ManifestConfig`
that drives the operator-mode signing-key allowlist gate
(C10-ST-01) and pins the Manifest schema version.
Backbone enumeration is config-driven (not hardcoded) so a new model
is a YAML change rather than a code change — see the AZ-321 task
spec §Constraints.
@@ -15,16 +19,89 @@ spec §Constraints.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from gps_denied_onboard.config.schema import ConfigError
__all__ = [
"BackboneConfig",
"C10ManifestConfig",
"C10ProvisioningConfig",
"SigningMode",
]
_DEFAULT_WORKSPACE_MB: int = 4096
_DEFAULT_MANIFEST_SCHEMA_VERSION: str = "1.1"
class SigningMode(str, Enum):
"""C10 Manifest signing-mode (AZ-323, C10-ST-01).
``OPERATOR``: production — the key fingerprint MUST be in
:attr:`C10ManifestConfig.allowed_operator_fingerprints` or the
build fails closed with :class:`ManifestWriteError`.
``DEV``: development / research — any key is accepted; an
operator-allowlisted key used in this mode emits a WARN log.
"""
OPERATOR = "operator"
DEV = "dev"
@dataclass(frozen=True)
class C10ManifestConfig:
"""Sub-block driving :class:`ManifestBuilder` policy (AZ-323).
``signing_mode`` controls the operator-key allowlist gate
(C10-ST-01). ``allowed_operator_fingerprints`` is the SHA-256 hex
of the raw 32-byte Ed25519 public key — 64 lowercase hex chars per
entry. ``schema_version`` is written into the Manifest body so the
AZ-324 verifier knows which optional blocks (e.g. ``flight``,
added in ADR-010 / v1.1) to expect.
"""
signing_mode: SigningMode = SigningMode.DEV
allowed_operator_fingerprints: tuple[str, ...] = ()
schema_version: str = _DEFAULT_MANIFEST_SCHEMA_VERSION
def __post_init__(self) -> None:
if not self.schema_version:
raise ConfigError(
"C10ManifestConfig.schema_version must be a non-empty string"
)
seen: set[str] = set()
for fp in self.allowed_operator_fingerprints:
if not isinstance(fp, str):
raise ConfigError(
"C10ManifestConfig.allowed_operator_fingerprints entries "
f"must be strings; got {type(fp).__name__}"
)
if len(fp) != 64:
raise ConfigError(
"C10ManifestConfig.allowed_operator_fingerprints entries "
f"must be 64-hex-char SHA-256 digests; got {len(fp)} chars "
f"for {fp!r}"
)
if fp.lower() != fp:
raise ConfigError(
"C10ManifestConfig.allowed_operator_fingerprints entries "
f"must be lowercase hex; got {fp!r}"
)
try:
int(fp, 16)
except ValueError as exc:
raise ConfigError(
"C10ManifestConfig.allowed_operator_fingerprints contains "
f"non-hex entry {fp!r}"
) from exc
if fp in seen:
raise ConfigError(
"C10ManifestConfig.allowed_operator_fingerprints contains "
f"duplicate fingerprint {fp!r}"
)
seen.add(fp)
@dataclass(frozen=True)
@@ -88,10 +165,16 @@ class C10ProvisioningConfig:
into :class:`BuildConfig`; defaults to 4 GiB which matches the
C7 NFT-LIM-01 GPU memory budget. Operators can dial it down for
Tier-2 compile workstations with less GPU memory.
``manifest`` carries the AZ-323 Manifest-builder policy
(signing mode, allowed operator fingerprints, schema version).
Defaulted to dev-mode with no allowlist so unit tests + replay
runs that don't build Manifests stay no-op.
"""
backbones: tuple[BackboneConfig, ...] = field(default_factory=tuple)
workspace_mb: int = _DEFAULT_WORKSPACE_MB
manifest: C10ManifestConfig = field(default_factory=C10ManifestConfig)
def __post_init__(self) -> None:
if self.workspace_mb <= 0:
@@ -52,6 +52,10 @@ from gps_denied_onboard._types.inference import (
OptimizationProfile,
PrecisionMode,
)
from gps_denied_onboard._types.inference_errors import (
CalibrationCacheError,
EngineBuildError,
)
from gps_denied_onboard._types.manifests import HostCapabilities
from gps_denied_onboard.helpers.engine_filename_schema import (
EngineFilenameSchema,
@@ -275,14 +279,14 @@ class EngineCompiler:
entry = self._runtime.compile_engine(
backbone.onnx_path, build_config
)
except Exception as exc:
# The C7 InferenceRuntime contract scopes exceptions to its
# `RuntimeError` family (`EngineBuildError`,
# `CalibrationCacheError`, ...). The c10 layer is forbidden
# from importing the c7 errors module (architecture rule
# AC-6 / test_az270_compose_root.test_ac6); we catch the
# broader `Exception` and dispatch by class name in the log
# payload. Re-raising preserves the original type.
except (EngineBuildError, CalibrationCacheError) as exc:
# AZ-507 narrowed the catch to the documented C7 typed-error
# envelope (`_types/inference_errors.py` re-exports
# `EngineBuildError` + `CalibrationCacheError` from
# `c7_inference.errors` without violating the AZ-270 lint).
# Unknown exceptions intentionally propagate unhandled — they
# are programmer errors, not C7 contract failures, and must
# not be swallowed under a structured "compile.error" log.
self._log.error(
"c10.engine.compile.error",
extra={
@@ -0,0 +1,38 @@
"""C10 cache-provisioning error family.
Rooted at :class:`C10ProvisioningError`; today the family contains
:class:`ManifestWriteError` (AZ-323) covering signing-key load failure,
fingerprint-allowlist rejection, and any I/O failure path during
``ManifestBuilder.build_manifest``. AZ-324 / AZ-325 add additional
subtypes (``ManifestVerifierError``, ``ManifestCoverageError``,
``ContentHashMismatchError``) under the same root as they land.
"""
from __future__ import annotations
__all__ = [
"C10ProvisioningError",
"ManifestWriteError",
]
class C10ProvisioningError(Exception):
"""Base class for the C10 cache-provisioning error family."""
class ManifestWriteError(C10ProvisioningError):
"""``ManifestBuilder.build_manifest`` could not produce a signed Manifest.
Surfaces three failure modes:
1. Operator-mode signing key fingerprint not in the configured
allowlist (C10-ST-01).
2. Signing key file unreadable or malformed PEM (the underlying
``cryptography`` exception is chained via ``__cause__``).
3. Any underlying atomic-write / sidecar failure during Manifest
or signature emission.
Callers catch this single envelope; the structured `kind=
"c10.manifest.build.error"` log payload (set by ``ManifestBuilder``)
carries the discriminator field.
"""
@@ -1,4 +1,8 @@
"""C10 `CacheProvisioner` Protocol.
"""C10 Public-API Protocols.
- :class:`CacheProvisioner` (AZ-325, pending) — pre-flight orchestrator.
- :class:`ManifestSigner` (AZ-323) — Ed25519 detached signing surface
consumed by :class:`ManifestBuilder`.
Concrete impl: engine compile + descriptors + manifest + content-hash gate. See
`_docs/02_document/components/11_c10_provisioning/`.
@@ -7,12 +11,58 @@ Concrete impl: engine compile + descriptors + manifest + content-hash gate. See
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from typing import Protocol, runtime_checkable
from gps_denied_onboard._types.manifests import Manifest
__all__ = [
"CacheProvisioner",
"ManifestSigner",
"SigningKeyHandle",
]
class CacheProvisioner(Protocol):
"""Pre-flight cache provisioning (engine compile + descriptor batch + manifest)."""
def provision(self, flight_id: str, output_root: Path) -> Manifest: ...
class SigningKeyHandle(Protocol):
"""Opaque handle returned by :meth:`ManifestSigner.load_signing_key`.
The Protocol intentionally exposes no methods — concrete signers
(e.g. :class:`Ed25519ManifestSigner`) hold the actual key behind
this marker so the caller can pass it back into :meth:`sign` /
:meth:`public_key_fingerprint` without ever touching the secret
material.
"""
@runtime_checkable
class ManifestSigner(Protocol):
"""Detached-signature provider for :class:`ManifestBuilder` (AZ-323).
Default impl is :class:`Ed25519ManifestSigner` using
``cryptography.hazmat.primitives.asymmetric.ed25519``; tests
inject a deterministic in-memory keypair.
Contract:
- :meth:`load_signing_key` takes a path to an operator-supplied
PEM-encoded PKCS8 Ed25519 private key, returns an opaque
:class:`SigningKeyHandle`. Format errors raise
:class:`gps_denied_onboard.components.c10_provisioning.errors.ManifestWriteError`
with the underlying ``cryptography`` exception chained via
``__cause__``.
- :meth:`sign` produces a 64-byte raw Ed25519 signature over the
payload bytes. Re-entry-safe; a single handle may be used to
sign many payloads.
- :meth:`public_key_fingerprint` returns the SHA-256 hex digest of
the raw 32-byte public key (operator-mode allowlist key).
"""
def load_signing_key(self, key_path: Path) -> SigningKeyHandle: ...
def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes: ...
def public_key_fingerprint(self, key: SigningKeyHandle) -> str: ...
@@ -0,0 +1,675 @@
"""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.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",
"TileHashRecord",
"TilesByBboxQuery",
]
_BUILD_LOG_KIND_PREFIX = "c10.manifest"
_TAKEOFF_ORIGIN_DECIMALS = 9
_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"}
)
@dataclass(frozen=True)
class TileHashRecord:
"""Consumer-side DTO carrying the four sort keys + the per-tile digest.
AZ-323 only needs ``(zoom, lat, lon, source)`` for canonical
ordering and ``sha256_hex`` for the aggregate hash. The
composition-root adapter wraps C6's ``TileMetadata`` rows into
this shape so the AZ-270 lint stays green (no
``components.c6_tile_cache`` import from C10).
"""
zoom: int
lat: float
lon: float
source: str
sha256_hex: str
@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 _aggregate_tile_hash(records: tuple[TileHashRecord, ...]) -> str:
hasher = hashlib.sha256()
for r in records:
hasher.update(
(
f"z{r.zoom}|lat{r.lat:.9f}|lon{r.lon:.9f}|src{r.source}"
f":{r.sha256_hex}\n"
).encode("ascii")
)
return hasher.hexdigest()
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 _compute_manifest_hash(
*,
engine_entries: tuple[EngineCacheEntry, ...],
calibration_sha256: str,
descriptor_index_sha256: str,
tiles_coverage_sha256: str,
sector_class: str,
bbox: BoundingBox,
zoom_levels: tuple[int, ...],
takeoff_origin: LatLonAlt | None,
flight_id: UUID | None,
) -> str:
# Engine identity is `(model_name, precision, sm, jetpack, trt, sha256)`
# so a stale-host fp16 build never collides with a fresh int8 build —
# this matches the AZ-281 filename schema fields modulo the precision
# axis (which fp16 vs int8 makes load-bearing).
model_ids = sorted(
(
str(entry.engine_path),
entry.sha256_hex,
)
for entry in engine_entries
)
origin_tuple: tuple[float, float, float] | None
if takeoff_origin is not None:
origin_tuple = (
round(takeoff_origin.lat_deg, _TAKEOFF_ORIGIN_DECIMALS),
round(takeoff_origin.lon_deg, _TAKEOFF_ORIGIN_DECIMALS),
round(takeoff_origin.alt_m, _TAKEOFF_ORIGIN_DECIMALS),
)
else:
origin_tuple = None
build_identity = {
"model_ids": [list(entry) for entry in model_ids],
"calibration_sha256": calibration_sha256,
"descriptor_index_sha256": descriptor_index_sha256,
"tiles_coverage_sha256": tiles_coverage_sha256,
"sector_class": sector_class,
"bbox": [
bbox.min_lat_deg,
bbox.min_lon_deg,
bbox.max_lat_deg,
bbox.max_lon_deg,
],
"zoom_levels": sorted(zoom_levels),
"takeoff_origin": list(origin_tuple) if origin_tuple is not None else None,
"flight_id": str(flight_id) if flight_id is not None else None,
}
canonical = orjson.dumps(build_identity, option=orjson.OPT_SORT_KEYS)
return hashlib.sha256(canonical).hexdigest()
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))
@@ -0,0 +1,748 @@
"""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.manifest_builder import (
TilesByBboxQuery,
_aggregate_tile_hash,
)
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,
)