Files
gps-denied-onboard/_docs/02_tasks/done/AZ-395_c8_mavlink_signing.md
T
Oleksandr Bezdieniezhnykh 1e0be08e8a [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>
2026-05-11 04:47:44 +03:00

8.6 KiB

C8 MAVLink 2.0 per-flight signing handshake (AP only) — D-C8-9 / R03

Task: AZ-395_c8_mavlink_signing Name: C8 AP MAVLink 2.0 per-flight signing — handshake + key rotation + zeroisation (D-C8-9 = (d), R03) Description: Extend PymavlinkArdupilotAdapter with MAVLink 2.0 per-flight signing per D-C8-9 = (d) (R03 risk; gated for production by IT-3 SITL pass per ADR-008). Generate a fresh ephemeral signing key at takeoff via secrets.token_bytes(32); complete the pymavlink signing handshake during open(port, signing_key); tighten open(...) to REJECT signing_key=None (replacing the placeholder accept from AZ-393 AP outbound task). Per-flight key rotation: a new key is generated on every open(...) call (one per flight). Key zeroisation on close(): overwrite the key buffer with b"\x00" * 32 BEFORE the buffer is deallocated. Key NEVER written to disk; NEVER appears in any log line, FDR record, or stderr trace. Mid-flight signing failure (link drop / counter desync): emit ERROR log, FDR record kind="c8.ap.signing_failure", STATUSTEXT to GCS — but DO NOT raise; the FC ignores unsigned messages and AC-5.2 fallback (AZ-388) takes over downstream. FDR signing-key rotation event ALWAYS emitted at open(...) with kind="c8.ap.signing_key_rotated" and {flight_id, key_age_s: 0} (NO key bytes). Complexity: 5 points Dependencies: AZ-393 (PymavlinkArdupilotAdapter skeleton); AZ-390 (SigningHandshakeError + SigningKeyExpiredError); AZ-273 (FDR), AZ-272, AZ-263, AZ-269, AZ-266 Component: c8_fc_adapter (epic AZ-261 / E-C8) Tracker: AZ-395 Epic: AZ-261 (E-C8)

Document Dependencies

  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md — Invariants 2, 10.
  • _docs/02_document/components/10_c8_fc_adapter/description.md — § 5 error handling (signing failure path), § 9 logging (signing key rotation ALWAYS to FDR).
  • _docs/02_document/architecture.md — D-C8-9 = (d), R03, ADR-008 (IT-3 gate).

Problem

Without this task, the AP wired MAVLink channel is unsigned — operator-deployed flights would expose the FC to spoofed MAVLink injection (R03). D-C8-9 mandates per-flight signing on the AP wired channel. This task is gated for production by the IT-3 SITL handshake test (C8-ST-01) per ADR-008.

Outcome

  • Extension of pymavlink_ardupilot_adapter.py:
    • __init__ now also accepts the signing-key-source from config.fc.signing_key_source (always "ephemeral_per_flight" in production; "static_dev" in dev for repeatable tests — gated by BUILD_DEV_STATIC_KEY=ON).
    • open(port, signing_key):
      • Generates a fresh signing_key = secrets.token_bytes(32) if signing_key_source == "ephemeral_per_flight".
      • REJECTS signing_key=None (AC-1).
      • Calls pymavlink.mavutil.mavlink_connection(...).setup_signing(signing_key, ...).
      • On handshake failure: raises SigningHandshakeError("AP signing handshake failed; refusing takeoff") + ERROR log + FDR record kind="c8.ap.signing_handshake_failed".
      • On handshake success: INFO log + FDR record kind="c8.ap.signing_key_rotated" with {flight_id, key_age_s: 0} (NO key bytes).
    • close():
      • Zeroes the signing-key buffer in place before deallocation.
      • INFO log kind="c8.ap.signing_key_zeroised".
    • Mid-flight signing failure detection: pymavlink's signing_failure_count polled every emit; when it crosses a threshold (configurable; default 3), emit ERROR + FDR kind="c8.ap.signing_failure" + STATUSTEXT — DO NOT raise.
  • Unit tests: ephemeral key generation, key rejection of None, handshake-failure raises, handshake-success logs, mid-flight signing-failure logs but does not raise, key zeroisation on close, key never appears in any log line / FDR record (regex-based assertion on captured logs).

Scope

Included

  • Ephemeral per-flight signing-key generation.
  • pymavlink signing handshake.
  • SigningHandshakeError raise on handshake failure (refuse takeoff per § 5 error-handling).
  • Mid-flight signing-failure path (log + STATUSTEXT, no raise).
  • Key zeroisation on close().
  • Key-rotation FDR event.
  • Tighten open(...) to reject signing_key=None.
  • Unit tests including secret-leakage regex assertion.
  • Integration with BUILD_DEV_STATIC_KEY=ON for repeatable dev tests.

Excluded

  • D-C8-2 source-set switch — owned by source-set task.
  • iNav signing — explicitly NOT applicable per RESTRICT-COMM-2 (AZ-394 iNav task rejects signing-key).
  • C8-ST-01 SITL handshake test — deferred to E-BBT (this task implements the surface; the IT-3 SITL gate exercises it).

Acceptance Criteria

AC-1: signing_key=None rejectionopen(port, signing_key=None) with signing_key_source="ephemeral_per_flight" → key is generated internally; signing_key_source="explicit" with None → SigningHandshakeError("signing_key required for AP").

AC-2: ephemeral key generation — every open(...) call generates a fresh 32-byte key from secrets.token_bytes; two consecutive opens produce distinct keys (probabilistic AC; key_a != key_b with probability ≈ 1).

AC-3: handshake failure raises — SITL refuses the handshake → SigningHandshakeError raised + ERROR log + FDR kind="c8.ap.signing_handshake_failed".

AC-4: handshake success logs (no key bytes) — successful handshake → FDR kind="c8.ap.signing_key_rotated" with {flight_id, key_age_s: 0}; assert NO byte sub-sequence of the key appears in the FDR record (regex / hex-substring scan).

AC-5: Key never in any log — capture all log lines + FDR records emitted during a 60-second flight; assert NO substring of the key appears in any. (Regex-based test against captured stdout + FDR.)

AC-6: Mid-flight signing failure no-raise — inject signing_failure_count = 5 → ERROR log + FDR kind="c8.ap.signing_failure" + STATUSTEXT — emit_external_position does NOT raise; subsequent emits continue.

AC-7: Key zeroisation on close — instrument the test harness to read the key buffer post-close (best-effort via process memory inspection or test hook); assert the buffer contains all-zero bytes; INFO log kind="c8.ap.signing_key_zeroised".

AC-8: BUILD_DEV_STATIC_KEY=ON repeatability — with the dev flag ON, two opens use the SAME static key (sourced from config.fc.dev_static_signing_key); production ignores this flag (rejected at config load if BUILD_DEV_STATIC_KEY=OFF AND dev_static_signing_key set).

AC-9: STATUSTEXT severity ERROR for handshake failure — handshake failure → STATUSTEXT severity = ERROR (mapped to MAVLink severity 3); mid-flight failure → STATUSTEXT severity = WARNING (4).

AC-10: Tighten existing AC — AZ-393 AP outbound task's AC-8 (placeholder accept of signing_key=None) is REPLACED by AC-1 of this task. Document the tightening in code-review hooks.

Non-Functional Requirements

  • pymavlink signing handshake p95 ≤ 1 s (sub-second per § 7).
  • Per-emit signing overhead p95 ≤ 0.5 ms (additive on top of the 5 ms emit budget).

Constraints

  • pymavlink bundled unmodified per D-C8-3.
  • Keys NEVER persisted to disk, NEVER logged, NEVER serialised.
  • BUILD_DEV_STATIC_KEY is OFF in production builds; refuse the dev-static path at composition root if the build flag is OFF and the config tries to use it.
  • The signing-failure-count threshold is configurable but must never be set so high that the system silently fails for an entire flight without operator notification.

Risks & Mitigation

  • R03 (no operator-deployed precedent)Mitigation: gated by IT-3 SITL handshake pass; D-C8-2-FALLBACK options recorded if IT-3 escalates.
  • R09 (key compromise)Mitigation: per-flight ephemeral keys + zeroisation + never-on-disk + never-in-logs.
  • Risk: key zeroisation flake under Python's GCMitigation: use bytearray for the key buffer (mutable; can be overwritten in place); explicit overwrite via for i in range(len(buf)): buf[i] = 0 BEFORE letting GC reclaim it; harness test inspects buffer post-close.
  • Risk: log-leak via uncaught tracebackMitigation: AC-5 regex scan on captured logs; pymavlink calls wrapped in narrow try/except that logs the error class + message but never the locals.

Runtime Completeness

  • Named capability: AP MAVLink 2.0 per-flight signing handshake + zeroisation.
  • Production code: real secrets.token_bytes; real pymavlink setup_signing; real zeroisation; real FDR rotation event.
  • Unacceptable substitutes: a hardcoded global signing key (defeats R03 + R09); skipping zeroisation (defeats Invariant 10).

Contract

Implements _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md — Invariants 2, 10. Delivers C8-ST-01 (gated by IT-3) wire surface.