"""NFT-SEC-03 — AP rejects unsigned / wrong-key / replayed messages (AZ-438 / AC-NEW-11). AP-only. Three sub-cases (sent in order; the runner pauses between each): * (a) unsigned ``GPS_INPUT``; * (b) signed-with-wrong-key ``GPS_INPUT``; * (c) replayed-from-tlog signed ``GPS_INPUT`` (counter-replay attack). For each: AP MUST emit ``BAD_SIGNATURE`` (or one of the documented equivalent rejection STATUSTEXTs) within ≤500 ms; AP's ``GLOBAL_POSITION_INT`` must NOT update from the injected message (``position_drift_m ≤ 1 m`` tolerance). iNav is N/A — MSP has no signing layer; the test skips when ``fc_adapter == 'inav'`` (AC-1). vio_strategy parameterization (AC-5) runs the AP probe under each strategy because the conftest matrix already enforces it; the SUT's VIO is irrelevant to the AP-side rejection but the parameterization keeps evidence symmetric across the test matrix. Production dependencies surfaced to AZ-595 / SUT: * fixture JSON shape (below) is sourced from a ``ap-only`` SITL replay with the three injection timestamps + AP STATUSTEXT capture + AP ``GLOBAL_POSITION_INT`` capture; * AP build MUST have MAVLink 2.0 signing enabled (per FT-P-09-AP / AZ-416 handshake); otherwise the rejection STATUSTEXT is never emitted and every sub-case fails on AC-2 — a fail-safe outcome, but the test will be noisy until the handshake fixture is wired. Fixture JSON shape:: { "injections": [ {"sub_case": "unsigned"|"wrong_key"|"replayed", "injected_at_ms": }, ... ], "statustexts": [{"monotonic_ms": , "text": }, ...], "positions": [{"monotonic_ms": , "lat_e7": , "lon_e7": }, ...] } """ from __future__ import annotations import json import os from pathlib import Path import pytest from runner.helpers import mavlink_signing_evaluator as mse NFT_SEC_03_FIXTURE_ENV_VAR = "E2E_NFT_SEC_03_FIXTURE" NFT_SEC_03_DEFAULT_FIXTURE_NAME = "nft_sec_03_mavlink_signing.json" @pytest.mark.scenario_id("nft-sec-03") @pytest.mark.traces_to("AC-NEW-11,AC-1,AC-2,AC-3,AC-4,AC-5") def test_nft_sec_03_mavlink_signing( fc_adapter: str, vio_strategy: str, evidence_dir, # type: ignore[no-untyped-def] run_id: str, nfr_recorder, # type: ignore[no-untyped-def] sitl_replay_ready: bool, ) -> None: """AP rejects all three injection sub-cases within ≤500 ms; no position drift.""" if fc_adapter == "inav": pytest.skip( "AC-1: NFT-SEC-03 is AP-only; iNav (MSP) has no signing layer." ) if not sitl_replay_ready: pytest.skip( "NFT-SEC-03 requires `E2E_SITL_REPLAY_DIR` to point at a " "prepared SITL replay fixture (AZ-595) carrying the three " "injection timestamps + AP STATUSTEXT + GLOBAL_POSITION_INT " "captures. Pure rejection-logic covered by " "e2e/_unit_tests/helpers/test_mavlink_signing_evaluator.py." ) fixture_path = _resolve_fixture_path() if not fixture_path.is_file(): pytest.fail( f"NFT-SEC-03: fixture not found at {fixture_path}. " f"`{NFT_SEC_03_FIXTURE_ENV_VAR}` env var must point at a JSON " "file with the schema documented in the scenario docstring. " "Production dependency: AZ-595 + FT-P-09-AP signing handshake " "(AZ-416)." ) payload = json.loads(fixture_path.read_text()) injections, statustexts, positions = _parse_payload(payload, fixture_path) if len(injections) != 3: pytest.fail( f"NFT-SEC-03 AC-2..AC-4: fixture must contain exactly 3 " f"injections (unsigned + wrong_key + replayed); got " f"{len(injections)} in {fixture_path}." ) sub_cases_seen = {inj.sub_case for inj in injections} expected = {mse.SubCase.UNSIGNED, mse.SubCase.WRONG_KEY, mse.SubCase.REPLAYED} if sub_cases_seen != expected: pytest.fail( f"NFT-SEC-03: fixture missing sub-cases {sorted(s.value for s in expected - sub_cases_seen)} " f"in {fixture_path}." ) report = mse.evaluate( injections, statustexts=statustexts, positions=positions ) out_csv = ( evidence_dir / "nft-sec-03" / f"{fc_adapter}-{vio_strategy}.csv" ) mse.write_csv_evidence(out_csv, report) for sub in report.sub_cases: if sub.rejection_latency_ms is not None: nfr_recorder.record_metric( f"nft_sec_03.{sub.sub_case.value}.rejection_latency_ms", float(sub.rejection_latency_ms), ac_id=_ac_for(sub.sub_case), ) nfr_recorder.record_metric( f"nft_sec_03.{sub.sub_case.value}.position_drift_m", sub.position_drift_m, ac_id=_ac_for(sub.sub_case), ) for sub in report.sub_cases: ac = _ac_for(sub.sub_case) assert sub.passes_rejection, ( f"{ac}: AP did not reject {sub.sub_case.value} GPS_INPUT within " f"{sub.budget_ms} ms — rejection_at_ms={sub.rejection_at_ms}, " f"rejection_text={sub.rejection_text!r}, " f"latency_ms={sub.rejection_latency_ms}." ) assert sub.passes_no_position_update, ( f"{ac}: AP GLOBAL_POSITION_INT drifted " f"{sub.position_drift_m:.2f} m around injection (tolerance " f"{mse.POSITION_DRIFT_TOLERANCE_M} m) — the rejection STATUSTEXT " f"fired but the position update was accepted. This is a " f"defense-bypass bug (signaling-only rejection without state " f"enforcement)." ) def _ac_for(sub_case: mse.SubCase) -> str: return { mse.SubCase.UNSIGNED: "AC-2", mse.SubCase.WRONG_KEY: "AC-3", mse.SubCase.REPLAYED: "AC-4", }[sub_case] def _resolve_fixture_path() -> Path: raw = os.environ.get(NFT_SEC_03_FIXTURE_ENV_VAR, "").strip() from runner.helpers import sitl_observer root = sitl_observer.replay_dir() if not raw: if root is None: return Path(f"<{NFT_SEC_03_FIXTURE_ENV_VAR}-unset>") return root / NFT_SEC_03_DEFAULT_FIXTURE_NAME path = Path(raw) if not path.is_absolute() and root is not None: path = root / path return path def _parse_payload( payload: object, fixture_path: Path ) -> tuple[ list[mse.InjectionEvent], list[mse.StatustextSample], list[mse.PositionSample], ]: if not isinstance(payload, dict): pytest.fail( f"NFT-SEC-03: fixture {fixture_path} must be a JSON object; " f"got top-level type={type(payload).__name__}" ) raw_inj = payload.get("injections") if not isinstance(raw_inj, list): pytest.fail( f"NFT-SEC-03: fixture {fixture_path} 'injections' must be a list" ) injections: list[mse.InjectionEvent] = [] for idx, entry in enumerate(raw_inj): if not isinstance(entry, dict): pytest.fail( f"NFT-SEC-03: injections[{idx}] in {fixture_path} must be an object" ) try: sub_case = mse.SubCase(str(entry["sub_case"])) except (KeyError, ValueError) as exc: pytest.fail( f"NFT-SEC-03: injections[{idx}] in {fixture_path} 'sub_case' " f"must be one of {sorted(s.value for s in mse.SubCase)}; got {exc}" ) try: injections.append( mse.InjectionEvent( sub_case=sub_case, injected_at_ms=int(entry["injected_at_ms"]), ) ) except (KeyError, TypeError, ValueError) as exc: pytest.fail( f"NFT-SEC-03: injections[{idx}] in {fixture_path} shape invalid: {exc}" ) raw_st = payload.get("statustexts", []) if not isinstance(raw_st, list): pytest.fail( f"NFT-SEC-03: fixture {fixture_path} 'statustexts' must be a list" ) statustexts: list[mse.StatustextSample] = [] for idx, entry in enumerate(raw_st): if not isinstance(entry, dict): pytest.fail( f"NFT-SEC-03: statustexts[{idx}] in {fixture_path} must be an object" ) try: statustexts.append( mse.StatustextSample( monotonic_ms=int(entry["monotonic_ms"]), text=str(entry["text"]), ) ) except (KeyError, TypeError, ValueError) as exc: pytest.fail( f"NFT-SEC-03: statustexts[{idx}] in {fixture_path} shape invalid: {exc}" ) raw_pos = payload.get("positions", []) if not isinstance(raw_pos, list): pytest.fail( f"NFT-SEC-03: fixture {fixture_path} 'positions' must be a list" ) positions: list[mse.PositionSample] = [] for idx, entry in enumerate(raw_pos): if not isinstance(entry, dict): pytest.fail( f"NFT-SEC-03: positions[{idx}] in {fixture_path} must be an object" ) try: positions.append( mse.PositionSample( monotonic_ms=int(entry["monotonic_ms"]), lat_e7=int(entry["lat_e7"]), lon_e7=int(entry["lon_e7"]), ) ) except (KeyError, TypeError, ValueError) as exc: pytest.fail( f"NFT-SEC-03: positions[{idx}] in {fixture_path} shape invalid: {exc}" ) return injections, statustexts, positions