mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 19:31:15 +00:00
Decompose Step 6 snapshot: 140 task specs + contract docs
Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# Contract: ManifestVerifier (C10)
|
||||
|
||||
**Type**: Python Protocol (`@runtime_checkable`) — local in-process API.
|
||||
**Producer task**: AZ-324_c10_manifest_verifier
|
||||
**Consumers**:
|
||||
- C5 State Estimator / takeoff-arming gate (F2 phase) — refuses to arm if `verify_manifest` does not return `outcome=pass`. (E-C5 / AZ-249.)
|
||||
- C12 Operator Tooling — runs verify before flight handoff to surface drift between F1 build time and F2 takeoff (E-C12 / AZ-253).
|
||||
- C13 FDR — emits a `manifest.verify` record on every airborne verify call (`outcome` field gates downstream).
|
||||
|
||||
## Purpose
|
||||
|
||||
`ManifestVerifier` is the read-only validator for the C10-produced cache Manifest. It is the takeoff trust anchor for AC-NEW-1 ("no engine deserialization at takeoff before manifest verify") and D-C10-3 ("SHA-256 content-hash gate over every shipped artifact"). At F2 takeoff, every artifact listed in the Manifest is re-hashed and compared to its recorded digest; any mismatch fails the verdict and prevents arming. The Ed25519 signature over the Manifest is verified against a pinned operator public key before any artifact is touched — defence-in-depth against a spliced Manifest pointing at attacker-chosen content hashes.
|
||||
|
||||
## Public Surface
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManifestVerifier(Protocol):
|
||||
"""Read-only verifier for a C10-produced Manifest.json.
|
||||
|
||||
Fail-closed: any deviation in signature, schema, or per-artifact hash
|
||||
yields `VerificationResult(outcome=fail, ...)`. Never raises on a verify
|
||||
failure — operators / takeoff arming code branch on `outcome`.
|
||||
|
||||
Raises only on resource errors (Manifest.json missing, key file
|
||||
unreadable) — those are environment problems, not verify outcomes.
|
||||
"""
|
||||
|
||||
def verify_manifest(
|
||||
self,
|
||||
*,
|
||||
manifest_path: Path,
|
||||
trusted_public_keys: tuple[Ed25519PublicKey, ...],
|
||||
) -> VerificationResult: ...
|
||||
```
|
||||
|
||||
### DTOs
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class VerifyOutcome(Enum):
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
class VerifyFailReason(Enum):
|
||||
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"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactCheck:
|
||||
relative_path: str
|
||||
expected_sha256: str
|
||||
actual_sha256: str | None # None if file missing
|
||||
matched: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationResult:
|
||||
outcome: VerifyOutcome
|
||||
fail_reasons: tuple[VerifyFailReason, ...]
|
||||
fail_details: tuple[str, ...] # human-readable diagnostic per reason
|
||||
signing_public_key_fingerprint: str | None # populated when signature parses, even if untrusted
|
||||
per_artifact_checks: tuple[ArtifactCheck, ...]
|
||||
elapsed_ms: int
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
| ID | Invariant | Why |
|
||||
|----|-----------|-----|
|
||||
| MV-INV-1 | The verifier is fail-closed: any deviation produces `outcome=FAIL` with at least one `VerifyFailReason`; never returns `PASS` with non-empty `fail_reasons`. | AC-NEW-1 / D-C10-3 — takeoff cannot arm on a partial verify. |
|
||||
| MV-INV-2 | Signature verification happens BEFORE per-artifact hashing. If the signature is invalid or untrusted, no file content is read beyond the Manifest itself. | Defence-in-depth: a malicious Manifest must not trick the verifier into hashing attacker-chosen file paths. |
|
||||
| MV-INV-3 | The Manifest's own `Manifest.json.sha256` sidecar (written by AZ-323) must match `sha256(Manifest.json)`; mismatch is `MANIFEST_SELF_HASH_MISMATCH`. | The sidecar is the entry point of the chain of trust — drift here means tampering or atomic-write failure. |
|
||||
| MV-INV-4 | Per-artifact paths are interpreted relative to `manifest_path.parent`; absolute paths in the Manifest are rejected as `SCHEMA_VIOLATION`. | Prevents a malicious Manifest from pointing outside `cache_root`. |
|
||||
| MV-INV-5 | `tiles_coverage` mismatch is reported separately from `ARTIFACT_HASH_MISMATCH` because tiles are hashed in aggregate (per AZ-323). The verifier re-derives the aggregate hash from a `TileMetadataStore` query if available, OR (in airborne F2 mode) treats the recorded `tiles_coverage_sha256` as authoritative and only verifies the Manifest signature + non-tile artifacts. | Airborne C5 may not load 100k per-tile rows just to arm; the trust chain is signature → manifest_hash → tiles_coverage_sha256. C12 / operator mode does the full re-derivation. |
|
||||
| MV-INV-6 | The verifier never writes to disk, never opens network sockets, never calls C13. Telemetry is the caller's responsibility. | Read-only contract — composable in airborne C5 + operator C12 contexts without side-effect surprise. |
|
||||
| MV-INV-7 | `elapsed_ms` is recorded for every call (pass or fail) so operators and C5 can observe drift in verify cost on slow disks. | NFR for C10-PT-01's takeoff load budget. |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Signature production** — owned by AZ-323's `ManifestSigner`. The verifier never signs.
|
||||
- **Cache repair** — the verifier reports failures; rebuild is owned by AZ-325 (the orchestrator).
|
||||
- **Trusted-key distribution / revocation** — `trusted_public_keys` is supplied by the caller; this contract does not define a key registry.
|
||||
- **Coverage check (orphan files in cache_root)** — owned by AZ-325 (`ManifestCoverageError`); the verifier checks "every Manifest entry exists and matches", not "every cache_root file is in the Manifest".
|
||||
- **Rollback to prior-good Manifest** — out of scope; caller decides next action on `FAIL`.
|
||||
|
||||
## Versioning
|
||||
|
||||
- v1.0.0 — initial Protocol surface (this document).
|
||||
- Breaking changes — adding a required argument, removing a `VerifyFailReason`, changing semantics of an existing one — bump major.
|
||||
- Additive changes — new `VerifyFailReason` value, new optional kwarg on `verify_manifest`, new field on `VerificationResult` — bump minor. Consumers MUST handle unknown reasons gracefully (default to FAIL).
|
||||
- Patch — clarifications, doc edits, bug-fix tests.
|
||||
|
||||
| Version | Date | Notes | Author |
|
||||
|---------|------|-------|--------|
|
||||
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-324 (E-C10 decomposition) | autodev |
|
||||
|
||||
## Test Cases (consumer side)
|
||||
|
||||
| ID | Scenario | Expected Outcome |
|
||||
|----|----------|------------------|
|
||||
| MV-TC-1 | Valid Manifest + trusted key + all artifacts present + hashes match | `outcome=PASS`, empty `fail_reasons`, `per_artifact_checks` all `matched=True` |
|
||||
| MV-TC-2 | Manifest.json missing | `outcome=FAIL`, `fail_reasons=(MANIFEST_NOT_FOUND,)`; no further work |
|
||||
| MV-TC-3 | Manifest.json.sig missing | `outcome=FAIL`, `fail_reasons=(SIGNATURE_NOT_FOUND,)`; signature_public_key_fingerprint=None |
|
||||
| MV-TC-4 | Signature does not verify | `outcome=FAIL`, `fail_reasons=(SIGNATURE_INVALID,)`; no per-artifact checks performed |
|
||||
| MV-TC-5 | Signature verifies but key is not in `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)`; fingerprint populated |
|
||||
| MV-TC-6 | Schema violation (missing required key, absolute path, wrong types) | `outcome=FAIL`, `fail_reasons=(SCHEMA_VIOLATION,)` with detail naming the field |
|
||||
| MV-TC-7 | One engine missing on disk | `outcome=FAIL`, `fail_reasons=(ARTIFACT_MISSING,)`; `per_artifact_checks` shows that engine with `actual_sha256=None, matched=False` |
|
||||
| MV-TC-8 | One engine present but bytes drifted | `outcome=FAIL`, `fail_reasons=(ARTIFACT_HASH_MISMATCH,)`; offending check has `matched=False` |
|
||||
| MV-TC-9 | Multiple failures (missing + drifted + signature OK) | `fail_reasons` contains BOTH `ARTIFACT_MISSING` and `ARTIFACT_HASH_MISMATCH`; per-artifact checks complete (don't short-circuit on first failure) |
|
||||
| MV-TC-10 | `Manifest.json.sha256` sidecar mismatch | `outcome=FAIL`, `fail_reasons=(MANIFEST_SELF_HASH_MISMATCH,)`; signature path NOT consulted |
|
||||
| MV-TC-11 | Tampered Manifest body but matching sidecar | `outcome=FAIL`, `fail_reasons=(SIGNATURE_INVALID,)` (the signature cannot match if body changed even by 1 byte) |
|
||||
| MV-TC-12 | Conformance: `isinstance(ManifestVerifier, my_impl)` | `True` |
|
||||
| MV-TC-13 | Tier-2 Tile-coverage check (operator mode with TileMetadataStore) | If recomputed `tiles_coverage_sha256` differs → `TILES_COVERAGE_MISMATCH`; if matches → that part passes |
|
||||
| MV-TC-14 | Empty `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` (every key is untrusted by definition) |
|
||||
| MV-TC-15 | Pristine Manifest verified inside 100 ms on Tier-2 (excludes per-tile re-walk) | `elapsed_ms ≤ 100` for the signature + non-tile artifact path |
|
||||
Reference in New Issue
Block a user