[AZ-393] [AZ-394] [AZ-395] C8 outbound chain + AP MAVLink2 signing

AZ-393 ArduPilot outbound: PymavlinkArdupilotAdapter encodes
EstimatorOutput to MAVLink2 GPS_INPUT via gps_input_send; emits
NAMED_VALUE_FLOAT(name="src_lbl") every frame and STATUSTEXT on
source_label transition (1 Hz per-severity cap). Smoothed-output
guard (Invariant 6), single-writer thread (Invariant 8), SPD
propagation. Shared helper _outbound_provenance.py owns the
canonical source-label-to-float table + transition rate-limiter.

AZ-394 iNav outbound: Msp2InavAdapter encodes EstimatorOutput to
hand-rolled MSP2_SENSOR_GPS (0x1F03, 52-byte LE payload via
_msp2_sensor_gps_encoder.py + YAMSPy send_RAW_msg). Secondary
unsigned MAVLink channel for STATUSTEXT transitions. open()
rejects non-None signing_key (RESTRICT-COMM-2 / Invariant 2);
request_source_set_switch raises SourceSetSwitchNotSupportedError
(Invariant 9 verified: never calls setup_signing on secondary).

AZ-395 AP MAVLink2 signing: ephemeral per-flight 32-byte key
from secrets.token_bytes; pymavlink setup_signing handshake at
open(); in-place bytearray zeroisation on close(); mid-flight
signing-failure detection (ERROR log + WARNING STATUSTEXT + no
raise; threshold configurable). Key never logged / persisted /
serialised (regex-scanned by AC-4/AC-5). BUILD_DEV_STATIC_KEY=ON
enables repeatable static-key dev path; rejected at open() when
the build flag is absent.

Shared: EstimatorOutput.smoothed (default False) added for the
Invariant 6 gate at the C8 boundary; FcConfig extended with
dev_static_signing_key + signing_failure_threshold (additive
defaults; cross-field validation in __post_init__).

Tests: 33 new AC tests (11 + 11 + 11) covering all 30 ACs; full
suite 476 passing / 2 skipped / 0 failing (was 443). Contract
surfaces unchanged at fc_adapter_protocol v1.0.0 and
composition_root v1.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 04:47:44 +03:00
parent a61d2d3f4b
commit 1e0be08e8a
16 changed files with 2198 additions and 4 deletions
@@ -0,0 +1,321 @@
"""AZ-393 — PymavlinkArdupilotAdapter outbound AC tests.
Covers all 10 ACs of AZ-393. The AP signing path is covered by
``test_az395_mavlink_signing.py``; this file uses
``signing_key_source="none"`` so the AP open path is signing-free.
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from unittest import mock
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
source_label_to_float,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
PymavlinkArdupilotAdapter,
)
from gps_denied_onboard.config import load_config
# ----------------------------------------------------------------------
# Helpers — pymavlink stand-in
class _MavStub:
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
def __init__(self) -> None:
self.gps_input_calls: list[tuple[Any, ...]] = []
self.named_value_float_calls: list[tuple[Any, ...]] = []
self.statustext_calls: list[tuple[int, bytes]] = []
def gps_input_send(self, *args: Any) -> None:
self.gps_input_calls.append(args)
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
self.named_value_float_calls.append((time_boot_ms, name, value))
def statustext_send(self, severity: int, text: bytes) -> None:
self.statustext_calls.append((severity, text))
class _ConnStub:
def __init__(self) -> None:
self.mav = _MavStub()
self.setup_signing_calls: list[bytes] = []
self.closed = False
def setup_signing(self, key: bytes) -> None:
self.setup_signing_calls.append(bytes(key))
def close(self) -> None:
self.closed = True
@pytest.fixture
def conn() -> _ConnStub:
return _ConnStub()
@pytest.fixture
def adapter(conn: _ConnStub, tmp_path) -> PymavlinkArdupilotAdapter:
cfg = _config_for_ap(tmp_path, signing_key_source="none")
fdr = mock.MagicMock()
cov = CovarianceProjector(fdr_client=fdr)
adapter = PymavlinkArdupilotAdapter(
config=cfg,
wgs_converter=mock.MagicMock(),
covariance_projector=cov,
fdr_client=fdr,
clock=lambda: 1.0,
flight_id="flt-test",
connect_factory=lambda device, baud: conn,
)
port = PortConfig(
fc_kind=FcKind.ARDUPILOT_PLANE,
device="/dev/null",
baud=921600,
)
adapter.open(port, signing_key=None)
yield adapter
adapter.close()
def _config_for_ap(tmp_path, *, signing_key_source: str = "none"):
"""Build a Config with ``fc.adapter='ardupilot_plane'``."""
env = {
"FC_ADAPTER": "ardupilot_plane",
"FC_PORT_DEVICE": "/dev/null",
"FC_PORT_BAUD": "921600",
"FC_SIGNING_KEY_SOURCE": signing_key_source,
}
return load_config(env=env, paths=(), require_env=False)
def _make_output(
*,
source_label: str = "visual_propagated",
smoothed: bool = False,
cov: np.ndarray | None = None,
wgs: LatLonAlt | None = None,
frame_id: int = 1,
) -> EstimatorOutput:
if cov is None:
cov = np.eye(6, dtype=np.float64) * 0.25
if wgs is None:
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
covariance_6x6=cov,
source_label=source_label,
health=None,
smoothed=smoothed,
extras={"wgs84": wgs},
)
# ----------------------------------------------------------------------
# AC-1: GPS_INPUT field fidelity
def test_ac1_gps_input_field_fidelity(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
# Arrange
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
output = _make_output(cov=cov, wgs=wgs)
expected_horiz_m = CovarianceProjector(
fdr_client=mock.MagicMock()
).to_ardupilot_horiz_accuracy_m(output)
# Act
result = adapter.emit_external_position(output)
# Assert
assert len(conn.mav.gps_input_calls) == 1
call = conn.mav.gps_input_calls[0]
# gps_input_send signature: (time_us, gps_id, ignore_flags, time_week_ms,
# time_week, fix_type, lat_e7, lon_e7, alt, hdop, vdop, vn, ve, vd,
# speed_accuracy, horiz_accuracy, vert_accuracy, sat_visible, yaw)
assert call[5] == 3 # fix type 3D
assert call[6] == int(wgs.lat_deg * 1e7)
assert call[7] == int(wgs.lon_deg * 1e7)
assert call[8] == pytest.approx(wgs.alt_m)
assert call[15] == pytest.approx(expected_horiz_m, abs=1e-3)
assert result.horiz_accuracy_m == pytest.approx(expected_horiz_m)
assert result.fc_kind is FcKind.ARDUPILOT_PLANE
assert result.sequence_number == 1
# ----------------------------------------------------------------------
# AC-2: GPS_INPUT every frame
def test_ac2_gps_input_every_frame(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
for i in range(100):
adapter.emit_external_position(_make_output(frame_id=i))
assert len(conn.mav.gps_input_calls) == 100
# ----------------------------------------------------------------------
# AC-3: NAMED_VALUE_FLOAT every frame, correct mapping
def test_ac3_named_value_float_every_frame(
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
) -> None:
for i in range(100):
adapter.emit_external_position(_make_output(frame_id=i))
assert len(conn.mav.named_value_float_calls) == 100
for _, name, value in conn.mav.named_value_float_calls:
assert name == b"src_lbl"
assert value == pytest.approx(source_label_to_float("visual_propagated"))
# ----------------------------------------------------------------------
# AC-4: STATUSTEXT rate-limited on transition
def test_ac4_statustext_only_on_transition(
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
) -> None:
"""100 frames, source_label toggles every 10; expect 10 transitions."""
# Loosen the 1 s per-severity hard cap for the test by patching the
# rate-limiter's min_interval_s — AC-4 measures the transition
# behaviour, not the secondary 1 Hz spam-defence cap.
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
labels = ["visual_propagated", "sat_anchored"]
for i in range(100):
label = labels[(i // 10) % 2]
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
# Each block of 10 frames is a single label; that's 10 blocks → 9
# transitions between adjacent blocks plus 1 for the initial
# None->visual_propagated bootstrap.
assert len(conn.mav.statustext_calls) == 10
def test_ac4_statustext_zero_within_state(
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
) -> None:
"""100 frames all on the same source label → exactly 1 STATUSTEXT (the bootstrap)."""
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
for i in range(100):
adapter.emit_external_position(_make_output(frame_id=i))
assert len(conn.mav.statustext_calls) == 1
# ----------------------------------------------------------------------
# AC-5: Smoothed output rejected
def test_ac5_smoothed_output_rejected(
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub, caplog: pytest.LogCaptureFixture
) -> None:
output = _make_output(smoothed=True)
with caplog.at_level(logging.ERROR), pytest.raises(FcEmitError, match="smoothed"):
adapter.emit_external_position(output)
assert len(conn.mav.gps_input_calls) == 0
assert any(getattr(rec, "kind", None) == "c8.ap.emit_failed" for rec in caplog.records)
# ----------------------------------------------------------------------
# AC-6: Non-SPD covariance rejected
def test_ac6_non_spd_covariance_rejected(
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
) -> None:
bad_cov = np.eye(6, dtype=np.float64)
bad_cov[0, 0] = -1.0 # not positive-definite
output = _make_output(cov=bad_cov)
with pytest.raises(FcEmitError):
adapter.emit_external_position(output)
assert len(conn.mav.gps_input_calls) == 0
# ----------------------------------------------------------------------
# AC-7: Single-writer thread
def test_ac7_single_writer_thread(adapter: PymavlinkArdupilotAdapter) -> None:
adapter.emit_external_position(_make_output()) # binds to main thread
err: list[BaseException] = []
def run() -> None:
try:
adapter.emit_external_position(_make_output(frame_id=2))
except RuntimeError as e:
err.append(e)
t = threading.Thread(target=run)
t.start()
t.join(timeout=2.0)
assert len(err) == 1
assert "single-writer" in str(err[0]).lower()
# ----------------------------------------------------------------------
# AC-8: Open without signing key (placeholder; AZ-395 tightens this)
def test_ac8_open_without_signing_key_succeeds(conn: _ConnStub, tmp_path) -> None:
cfg = _config_for_ap(tmp_path, signing_key_source="none")
fdr = mock.MagicMock()
a = PymavlinkArdupilotAdapter(
config=cfg,
wgs_converter=mock.MagicMock(),
covariance_projector=CovarianceProjector(fdr_client=fdr),
fdr_client=fdr,
connect_factory=lambda device, baud: conn,
)
port = PortConfig(
fc_kind=FcKind.ARDUPILOT_PLANE,
device="/dev/null",
baud=921600,
)
a.open(port, signing_key=None)
try:
assert a._opened is True
finally:
a.close()
# ----------------------------------------------------------------------
# AC-9: source-set switch raises NotImplementedError
def test_ac9_source_set_switch_not_implemented(adapter: PymavlinkArdupilotAdapter) -> None:
with pytest.raises(NotImplementedError, match="AZ-396"):
adapter.request_source_set_switch()
# ----------------------------------------------------------------------
# AC-10: First emit logged once
def test_ac10_first_emit_logged_once(
adapter: PymavlinkArdupilotAdapter, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.INFO):
for i in range(5):
adapter.emit_external_position(_make_output(frame_id=i))
first_emit_records = [
rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.ap.first_emit"
]
assert len(first_emit_records) == 1
@@ -0,0 +1,301 @@
"""AZ-394 — Msp2InavAdapter outbound AC tests."""
from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from unittest import mock
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
from gps_denied_onboard.components.c8_fc_adapter._msp2_sensor_gps_encoder import (
MSP2_SENSOR_GPS_CODE,
decode_msp2_sensor_gps,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import (
FcAdapterConfigError,
FcEmitError,
SourceSetSwitchNotSupportedError,
)
from gps_denied_onboard.components.c8_fc_adapter.msp2_inav_adapter import (
Msp2InavAdapter,
)
from gps_denied_onboard.config import load_config
# ----------------------------------------------------------------------
# Helpers — MSP / secondary-MAVLink stand-ins
class _MspStub:
def __init__(self) -> None:
self.raw_msgs: list[tuple[int, bytes]] = []
self.closed = False
def send_RAW_msg(self, code: int, data: bytes) -> None:
self.raw_msgs.append((int(code), bytes(data)))
def close(self) -> None:
self.closed = True
class _SecondaryMavStub:
def __init__(self) -> None:
self.statustext_calls: list[tuple[int, bytes]] = []
self.closed = False
# Mirror pymavlink connection.mav shape.
self.mav = self
# Track signing-key state per RESTRICT-COMM-2 (Invariant 9):
# the unit-test adapter MUST never call setup_signing on us.
self.setup_signing_calls: list[Any] = []
def statustext_send(self, severity: int, text: bytes) -> None:
self.statustext_calls.append((int(severity), bytes(text)))
def setup_signing(self, key: Any) -> None:
self.setup_signing_calls.append(key)
def close(self) -> None:
self.closed = True
@pytest.fixture
def msp() -> _MspStub:
return _MspStub()
@pytest.fixture
def secondary() -> _SecondaryMavStub:
return _SecondaryMavStub()
def _inav_config(tmp_path) -> Any:
env = {
"FC_ADAPTER": "inav",
"FC_PORT_DEVICE": "/dev/null",
"FC_PORT_BAUD": "115200",
"FC_SIGNING_KEY_SOURCE": "none",
}
return load_config(env=env, paths=(), require_env=False)
def _make_output(
*,
source_label: str = "visual_propagated",
smoothed: bool = False,
cov: np.ndarray | None = None,
wgs: LatLonAlt | None = None,
frame_id: int = 1,
) -> EstimatorOutput:
if cov is None:
cov = np.eye(6, dtype=np.float64) * 0.25
if wgs is None:
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
covariance_6x6=cov,
source_label=source_label,
smoothed=smoothed,
extras={"wgs84": wgs},
)
@pytest.fixture
def adapter(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> Msp2InavAdapter:
cfg = _inav_config(tmp_path)
fdr = mock.MagicMock()
a = Msp2InavAdapter(
config=cfg,
wgs_converter=mock.MagicMock(),
covariance_projector=CovarianceProjector(fdr_client=fdr),
fdr_client=fdr,
msp_connect_factory=lambda device, baud: msp,
secondary_mavlink_factory=lambda: secondary,
)
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
a.open(port, signing_key=None)
yield a
a.close()
# ----------------------------------------------------------------------
# AC-1: field fidelity
def test_ac1_msp2_field_fidelity(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
# Arrange
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
output = _make_output(cov=cov, wgs=wgs)
expected_mm = CovarianceProjector(fdr_client=mock.MagicMock()).to_inav_h_pos_accuracy_mm(output)
# Act
result = adapter.emit_external_position(output)
# Assert
assert len(msp.raw_msgs) == 1
code, payload = msp.raw_msgs[0]
assert code == MSP2_SENSOR_GPS_CODE
decoded = decode_msp2_sensor_gps(payload)
assert decoded.fix_type == 3
assert decoded.latitude_e7 == int(wgs.lat_deg * 1e7)
assert decoded.longitude_e7 == int(wgs.lon_deg * 1e7)
assert decoded.msl_altitude_cm == int(wgs.alt_m * 100.0)
assert decoded.h_pos_accuracy_mm == expected_mm
assert result.fc_kind is FcKind.INAV
assert result.horiz_accuracy_m == pytest.approx(expected_mm / 1000.0)
# ----------------------------------------------------------------------
# AC-2: every frame, monotonic seq
def test_ac2_msp2_every_frame_with_seq(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
seqs = []
for i in range(100):
result = adapter.emit_external_position(_make_output(frame_id=i))
seqs.append(result.sequence_number)
assert len(msp.raw_msgs) == 100
# AC-2: monotonically incrementing (modulo uint8 wrap)
assert seqs[0] == 1
assert seqs[-1] == 100 & 0xFF
# ----------------------------------------------------------------------
# AC-3: STATUSTEXT on secondary channel only, on transitions
def test_ac3_statustext_secondary_only_on_transitions(
adapter: Msp2InavAdapter, msp: _MspStub, secondary: _SecondaryMavStub
) -> None:
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
labels = ["visual_propagated", "sat_anchored"]
for i in range(100):
label = labels[(i // 10) % 2]
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
# 10 transitions including the initial None->visual_propagated
assert len(secondary.statustext_calls) == 10
# AC-3: NEVER on primary MSP2 channel
for code, _ in msp.raw_msgs:
assert code == MSP2_SENSOR_GPS_CODE
# ----------------------------------------------------------------------
# AC-4: signing-key rejection
def test_ac4_signing_key_rejected(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> None:
cfg = _inav_config(tmp_path)
fdr = mock.MagicMock()
a = Msp2InavAdapter(
config=cfg,
wgs_converter=mock.MagicMock(),
covariance_projector=CovarianceProjector(fdr_client=fdr),
fdr_client=fdr,
msp_connect_factory=lambda device, baud: msp,
secondary_mavlink_factory=lambda: secondary,
)
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
with pytest.raises(FcAdapterConfigError, match="iNav does not support MAVLink signing"):
a.open(port, signing_key=b"\x00" * 32)
# ----------------------------------------------------------------------
# AC-5: signing-asymmetry — adapter never calls setup_signing on secondary
def test_ac5_signing_asymmetry_no_signed_flag(
adapter: Msp2InavAdapter, secondary: _SecondaryMavStub
) -> None:
for i in range(50):
adapter.emit_external_position(_make_output(frame_id=i))
assert secondary.setup_signing_calls == []
# ----------------------------------------------------------------------
# AC-6: source-set-switch unsupported
def test_ac6_source_set_switch_unsupported(adapter: Msp2InavAdapter) -> None:
with pytest.raises(SourceSetSwitchNotSupportedError, match="iNav"):
adapter.request_source_set_switch()
# ----------------------------------------------------------------------
# AC-7: smoothed rejected
def test_ac7_smoothed_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
with pytest.raises(FcEmitError, match="smoothed"):
adapter.emit_external_position(_make_output(smoothed=True))
assert msp.raw_msgs == []
# ----------------------------------------------------------------------
# AC-8: non-SPD cov rejected
def test_ac8_non_spd_covariance_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
bad = np.eye(6, dtype=np.float64)
bad[0, 0] = -1.0
with pytest.raises(FcEmitError):
adapter.emit_external_position(_make_output(cov=bad))
assert msp.raw_msgs == []
# ----------------------------------------------------------------------
# AC-9: single-writer thread
def test_ac9_single_writer_thread(adapter: Msp2InavAdapter) -> None:
adapter.emit_external_position(_make_output())
err: list[BaseException] = []
def run() -> None:
try:
adapter.emit_external_position(_make_output(frame_id=2))
except RuntimeError as e:
err.append(e)
t = threading.Thread(target=run)
t.start()
t.join(timeout=2.0)
assert len(err) == 1
assert "single-writer" in str(err[0]).lower()
# ----------------------------------------------------------------------
# AC-10: first emit logged once
def test_ac10_first_emit_logged_once(
adapter: Msp2InavAdapter, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.INFO):
for i in range(5):
adapter.emit_external_position(_make_output(frame_id=i))
first = [rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.inav.first_emit"]
assert len(first) == 1
# ----------------------------------------------------------------------
# Invariant 2 cross-check: iNav config rejects ephemeral signing
def test_inav_config_rejects_signing(tmp_path) -> None:
env = {
"FC_ADAPTER": "inav",
"FC_SIGNING_KEY_SOURCE": "ephemeral_per_flight",
}
with pytest.raises(Exception, match="adapter='inav'"):
load_config(env=env, paths=(), require_env=False)
@@ -0,0 +1,304 @@
"""AZ-395 — AP MAVLink 2.0 per-flight signing AC tests."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
from unittest import mock
import numpy as np
import pytest
from gps_denied_onboard._types.fc import FcKind, PortConfig, Severity
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.pose import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
CovarianceProjector,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import (
FcOpenError,
SigningHandshakeError,
)
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
PymavlinkArdupilotAdapter,
)
from gps_denied_onboard.config import load_config
_DEV_STATIC_KEY = "00112233445566778899aabbccddeeff" * 2 # 64 hex chars = 32 bytes
class _MavStub:
def __init__(self, signing_failure_count: int = 0) -> None:
self.gps_input_calls: list[tuple[Any, ...]] = []
self.named_value_float_calls: list[tuple[Any, ...]] = []
self.statustext_calls: list[tuple[int, bytes]] = []
# pymavlink exposes `connection.mav.signing.sig_count` after
# setup_signing(...); we simulate that surface here.
self.signing = SimpleNamespace(sig_count=signing_failure_count)
def gps_input_send(self, *args: Any) -> None:
self.gps_input_calls.append(args)
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
self.named_value_float_calls.append((time_boot_ms, name, value))
def statustext_send(self, severity: int, text: bytes) -> None:
self.statustext_calls.append((severity, text))
class _ConnStub:
def __init__(self, *, fail_signing: bool = False, signing_failure_count: int = 0) -> None:
self.mav = _MavStub(signing_failure_count=signing_failure_count)
self.setup_signing_calls: list[bytes] = []
self._fail_signing = fail_signing
self.closed = False
def setup_signing(self, key: bytes) -> None:
if self._fail_signing:
raise RuntimeError("simulated signing handshake refusal")
self.setup_signing_calls.append(bytes(key))
def close(self) -> None:
self.closed = True
def _ap_config(*, signing_key_source: str, dev_static_key: str = "") -> Any:
env: dict[str, str] = {
"FC_ADAPTER": "ardupilot_plane",
"FC_PORT_DEVICE": "/dev/null",
"FC_PORT_BAUD": "921600",
"FC_SIGNING_KEY_SOURCE": signing_key_source,
}
if dev_static_key:
env["FC_DEV_STATIC_SIGNING_KEY"] = dev_static_key
return load_config(env=env, paths=(), require_env=False)
def _build_adapter(
conn: _ConnStub, *, signing_key_source: str, dev_static_key: str = ""
) -> PymavlinkArdupilotAdapter:
cfg = _ap_config(signing_key_source=signing_key_source, dev_static_key=dev_static_key)
fdr = mock.MagicMock()
return PymavlinkArdupilotAdapter(
config=cfg,
wgs_converter=mock.MagicMock(),
covariance_projector=CovarianceProjector(fdr_client=fdr),
fdr_client=fdr,
flight_id="flt-az395",
connect_factory=lambda device, baud: conn,
)
def _port() -> PortConfig:
return PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
def _make_output(frame_id: int = 1) -> EstimatorOutput:
return EstimatorOutput(
frame_id=frame_id,
timestamp=datetime.now(tz=timezone.utc),
pose_se3=np.eye(4),
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
source_label="visual_propagated",
smoothed=False,
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
)
# ----------------------------------------------------------------------
# AC-1 / AC-10: signing_key=None with ephemeral source generates the key
def test_ac1_ephemeral_generates_key_when_none_passed() -> None:
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
adapter.open(_port(), signing_key=None)
try:
assert len(conn.setup_signing_calls) == 1
assert len(conn.setup_signing_calls[0]) == 32
finally:
adapter.close()
# ----------------------------------------------------------------------
# AC-2: two opens produce distinct ephemeral keys
def test_ac2_ephemeral_distinct_per_flight() -> None:
conn1 = _ConnStub()
a1 = _build_adapter(conn1, signing_key_source="ephemeral_per_flight")
a1.open(_port(), signing_key=None)
a1.close()
conn2 = _ConnStub()
a2 = _build_adapter(conn2, signing_key_source="ephemeral_per_flight")
a2.open(_port(), signing_key=None)
a2.close()
assert conn1.setup_signing_calls[0] != conn2.setup_signing_calls[0]
# ----------------------------------------------------------------------
# AC-3: handshake failure raises SigningHandshakeError
def test_ac3_handshake_failure_raises(caplog: pytest.LogCaptureFixture) -> None:
conn = _ConnStub(fail_signing=True)
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
with caplog.at_level(logging.ERROR), pytest.raises(SigningHandshakeError):
adapter.open(_port(), signing_key=None)
assert any(
getattr(rec, "kind", None) == "c8.ap.signing_handshake_failed" for rec in caplog.records
)
# ----------------------------------------------------------------------
# AC-4: handshake success FDR record has NO key bytes
def test_ac4_handshake_success_fdr_has_no_key_bytes() -> None:
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
adapter.open(_port(), signing_key=None)
try:
key = bytes(conn.setup_signing_calls[0])
rotated_calls = [
call
for call in adapter._fdr_client.enqueue.mock_calls # type: ignore[attr-defined]
if call.args[0].payload.get("kind") == "c8.ap.signing_key_rotated"
]
assert len(rotated_calls) == 1
rec_payload = rotated_calls[0].args[0].payload
# AC-4: assert NO key byte sub-sequence appears in the FDR payload.
rendered = str(rec_payload)
assert key.hex() not in rendered
for i in range(0, len(key) - 4):
chunk = key[i : i + 4].hex()
assert chunk not in rendered, f"4-byte chunk leak at offset {i}"
finally:
adapter.close()
# ----------------------------------------------------------------------
# AC-5: key never in any log line
def test_ac5_key_never_in_logs(caplog: pytest.LogCaptureFixture) -> None:
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
with caplog.at_level(logging.DEBUG):
adapter.open(_port(), signing_key=None)
for i in range(5):
adapter.emit_external_position(_make_output(frame_id=i))
adapter.close()
key = bytes(conn.setup_signing_calls[0])
rendered_logs = "\n".join(rec.getMessage() for rec in caplog.records)
assert key.hex() not in rendered_logs
pattern = re.compile(re.escape(key[:4].hex()))
assert pattern.search(rendered_logs) is None
# ----------------------------------------------------------------------
# AC-6: mid-flight signing failure does NOT raise
def test_ac6_mid_flight_signing_failure_no_raise(caplog: pytest.LogCaptureFixture) -> None:
conn = _ConnStub(signing_failure_count=5)
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
adapter.open(_port(), signing_key=None)
try:
with caplog.at_level(logging.ERROR):
adapter.emit_external_position(_make_output())
adapter.emit_external_position(_make_output(frame_id=2))
assert any(getattr(rec, "kind", None) == "c8.ap.signing_failure" for rec in caplog.records)
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
finally:
adapter.close()
# ----------------------------------------------------------------------
# AC-7: key zeroisation on close
def test_ac7_key_zeroisation_on_close(caplog: pytest.LogCaptureFixture) -> None:
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
adapter.open(_port(), signing_key=None)
# Grab a direct reference to the bytearray buffer so we can inspect
# it after close() — bytearray is mutable so the zeroisation in
# _zeroise_signing_key wipes this same buffer in place.
key_buf = adapter._signing_key
assert key_buf is not None
assert any(b != 0 for b in key_buf) # non-zero before close
with caplog.at_level(logging.INFO):
adapter.close()
assert all(b == 0 for b in key_buf)
assert any(getattr(rec, "kind", None) == "c8.ap.signing_key_zeroised" for rec in caplog.records)
# ----------------------------------------------------------------------
# AC-8: BUILD_DEV_STATIC_KEY=ON enables static repeatable key path
def test_ac8_dev_static_key_repeatable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BUILD_DEV_STATIC_KEY", "ON")
conn1 = _ConnStub()
a1 = _build_adapter(conn1, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
a1.open(_port(), signing_key=None)
a1.close()
conn2 = _ConnStub()
a2 = _build_adapter(conn2, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
a2.open(_port(), signing_key=None)
a2.close()
assert conn1.setup_signing_calls[0] == conn2.setup_signing_calls[0]
assert conn1.setup_signing_calls[0].hex() == _DEV_STATIC_KEY
def test_ac8_dev_static_key_blocked_without_build_flag(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BUILD_DEV_STATIC_KEY", raising=False)
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
with pytest.raises(FcOpenError, match="BUILD_DEV_STATIC_KEY"):
adapter.open(_port(), signing_key=None)
# ----------------------------------------------------------------------
# AC-9 (indirect): mid-flight signing failure STATUSTEXT severity = WARNING.
# AC-3 handshake-failure raises and logs ERROR; the STATUSTEXT severity is
# not exercised because handshake-failure aborts open() before any emit.
def test_ac9_statustext_severity_on_mid_flight_failure() -> None:
conn = _ConnStub(signing_failure_count=5)
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
adapter.open(_port(), signing_key=None)
try:
adapter.emit_external_position(_make_output())
# First STATUSTEXT is the signing-failure WARNING; subsequent
# provenance STATUSTEXT may follow at INFO. Assert at least one
# WARNING was emitted.
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
finally:
adapter.close()
# ----------------------------------------------------------------------
# AC-10: AZ-393 placeholder tightened — `signing_key_source="none"` is the
# only path that bypasses signing; `"ephemeral_per_flight"` rejects no key
# via an internally generated one (and AC-1 verified that). The matching
# tightening surface is that an unknown source is rejected.
def test_ac10_unknown_signing_source_rejected() -> None:
# FcConfig enforces the allowed-source list at config-load time, so
# an explicit "explicit" source is rejected before the adapter sees
# it. We exercise the FcOpenError path by patching the validated
# config in place (test seam).
conn = _ConnStub()
adapter = _build_adapter(conn, signing_key_source="none")
# Mutate the validated config in place to simulate an unknown source
# slipping past validation (defence-in-depth) — adapter must refuse.
object.__setattr__(adapter._config.fc, "signing_key_source", "bogus") # type: ignore[arg-type]
with pytest.raises(FcOpenError, match="unknown signing_key_source"):
adapter.open(_port(), signing_key=None)