mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:51:14 +00:00
[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:
@@ -1,104 +0,0 @@
|
||||
# C8 PymavlinkArdupilotAdapter — outbound GPS_INPUT + STATUSTEXT + NAMED_VALUE_FLOAT
|
||||
|
||||
**Task**: AZ-393_c8_ardupilot_outbound
|
||||
**Name**: C8 `PymavlinkArdupilotAdapter` outbound — `GPS_INPUT` 5 Hz + provenance side-channel
|
||||
**Description**: Implement the `PymavlinkArdupilotAdapter.emit_external_position(EstimatorOutput)` body: encode `EstimatorOutput` into a MAVLink 2.0 `GPS_INPUT` frame (lat/lon/alt from WGS84 conversion via injected `WgsConverter`; `horiz_accuracy` from the injected `CovarianceProjector.to_ardupilot_horiz_accuracy_m`; `vel_n/vel_e/vel_d` from the velocity sub-vector if present in the C5 estimate); write to the pymavlink connection via `mav.gps_input_send(...)`. Side-channel: emit `NAMED_VALUE_FLOAT` with `name="src_lbl"` carrying the `EstimatorOutput.source_label` enum value (encoded as float per the documented enum-to-float mapping); also emit `STATUSTEXT(severity=INFO, "src=<label>")` once per source-label transition (rate-limited — not on every frame). Body of `emit_status_text(msg, severity)` writes pymavlink `STATUSTEXT`. `request_source_set_switch` raises `NotImplementedError("source-set switch owned by AZ-396 task")` — replaced when that task lands. Smoothed-output guard (Invariant 6): `output.smoothed == True` → `FcEmitError`. SPD-violation propagates from `CovarianceProjector` as `FcEmitError`; logged + dropped + continue per § 5 error-handling. Single-writer-thread invariant enforced (Invariant 8). NO signing logic — that's a separate task; `open(...)` here accepts `signing_key=None` for now (signing task replaces this).
|
||||
**Complexity**: 5 points
|
||||
**Dependencies**: AZ-390 (Protocol + DTOs), AZ-392 (`CovarianceProjector` helper), AZ-279 (`WgsConverter`), AZ-273 (FDR), AZ-263, AZ-269, AZ-266, AZ-272 (FDR record schema)
|
||||
**Component**: c8_fc_adapter (epic AZ-261 / E-C8)
|
||||
**Tracker**: AZ-393
|
||||
**Epic**: AZ-261 (E-C8)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariants 3, 4, 5, 6, 8.
|
||||
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 3 External API (MAVLink 2.0), § 5 implementation details.
|
||||
- `_docs/02_document/architecture.md` — § 5 External Integrations.
|
||||
|
||||
## Problem
|
||||
|
||||
Without this task, AP-targeted flights cannot run: there is no GPS_INPUT being emitted to ArduPilot's EKF, and the provenance side-channel (`source_label` via NAMED_VALUE_FLOAT + STATUSTEXT) is not feeding GCS observability. The C5 emit driver has no sink.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` — `PymavlinkArdupilotAdapter` class implementing `FcAdapter`.
|
||||
- Constructor: `__init__(self, config, wgs_converter, covariance_projector, fdr_client, clock)`.
|
||||
- Body of `emit_external_position` — encode + emit `GPS_INPUT`; emit `NAMED_VALUE_FLOAT(name="src_lbl")` every frame; emit `STATUSTEXT` on `source_label` transition (rate-limited).
|
||||
- Body of `emit_status_text` — pymavlink `statustext_send`.
|
||||
- Body of `open(...)` — open pymavlink connection on the configured port with `mavlink_version=2`; reject `signing_key=None` only when the signing task has landed (this task accepts None and emits unsigned frames; a follow-up integration step in the signing task converts this to require non-None).
|
||||
- Body of `close()` — flush pending writes, close connection.
|
||||
- `request_source_set_switch` raises `NotImplementedError("Owned by source-set task; install AZ-396 to enable")`.
|
||||
- `current_flight_state` already implemented by AZ-391 (inbound task) — this task just doesn't override it.
|
||||
- INFO log on first successful `GPS_INPUT` emit: `kind="c8.ap.first_emit"`.
|
||||
- DEBUG log per emit: `kind="c8.ap.emit"` with `{frame_seq, horiz_accuracy_m, source_label}`.
|
||||
- ERROR log on `FcEmitError`: `kind="c8.ap.emit_failed"` with reason.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- `PymavlinkArdupilotAdapter` class implementing `FcAdapter`.
|
||||
- `GPS_INPUT` encoding from `EstimatorOutput`.
|
||||
- `NAMED_VALUE_FLOAT` source-label side-channel (every frame).
|
||||
- `STATUSTEXT` source-label transition (rate-limited).
|
||||
- `emit_status_text` body.
|
||||
- `open` / `close` body (without signing — placeholder).
|
||||
- Unit tests: encoding fidelity (decode the bytes back via pymavlink + assert fields); rate-limited STATUSTEXT (no flood); smoothed-output rejected; non-SPD covariance rejected; single-writer-thread enforcement.
|
||||
|
||||
### Excluded
|
||||
- MAVLink 2.0 signing handshake — owned by signing task.
|
||||
- D-C8-2 source-set switch — owned by source-set task.
|
||||
- iNav adapter — owned by iNav outbound task.
|
||||
- GCS adapter — owned by GCS task.
|
||||
- Inbound subscribe (already implemented in `_inbound_mavlink.py` via AZ-391 — this class composes it).
|
||||
- `current_flight_state` body — already implemented in inbound task.
|
||||
- C8-IT/PT/ST tests — deferred to E-BBT.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: GPS_INPUT field fidelity** — emit a known `EstimatorOutput`; decode the wire bytes via pymavlink; assert lat/lon/alt match WgsConverter output; `horiz_accuracy` matches `CovarianceProjector.to_ardupilot_horiz_accuracy_m(cov_6x6)` to within 1e-3 m.
|
||||
|
||||
**AC-2: GPS_INPUT every frame** — drive 100 frames; assert exactly 100 `GPS_INPUT` messages on the wire.
|
||||
|
||||
**AC-3: NAMED_VALUE_FLOAT every frame** — drive 100 frames; assert 100 `NAMED_VALUE_FLOAT` messages with `name="src_lbl"`; values match the source-label-to-float mapping.
|
||||
|
||||
**AC-4: STATUSTEXT rate-limited on transition** — drive 100 frames with `source_label` toggling every 10 frames; assert exactly 10 `STATUSTEXT` messages (one per transition); within-state frames emit zero STATUSTEXT.
|
||||
|
||||
**AC-5: Smoothed output rejected** — emit an `EstimatorOutput` with `smoothed=True`; assert `FcEmitError` raised + `kind="c8.ap.emit_failed"` logged + no GPS_INPUT on the wire.
|
||||
|
||||
**AC-6: Non-SPD covariance rejected** — emit an `EstimatorOutput` with non-SPD `covariance_6x6`; assert `FcEmitError` raised (propagated from `CovarianceProjector`) + log + no GPS_INPUT.
|
||||
|
||||
**AC-7: Single-writer thread** — call `emit_external_position` from a second thread; assert `RuntimeError`.
|
||||
|
||||
**AC-8: Open without signing key (placeholder)** — `open(port, signing_key=None)` succeeds in this task's context; signing task tightens this to reject None.
|
||||
|
||||
**AC-9: source-set switch raises NotImplementedError** — `request_source_set_switch()` raises with the message `"Owned by source-set task; install AZ-396 to enable"`.
|
||||
|
||||
**AC-10: First emit logged once** — the `kind="c8.ap.first_emit"` INFO log fires exactly once per `open(...)` lifetime.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- `emit_external_position` p95 ≤ 5 ms (C8-PT-01 budget).
|
||||
- p99 ≤ 8 ms (defensive headroom).
|
||||
|
||||
## Constraints
|
||||
|
||||
- pymavlink bundled unmodified per D-C8-3.
|
||||
- Single-writer thread enforced.
|
||||
- The source-label-to-float encoding mapping MUST be documented inline + match the operator-side decoder (E-C12).
|
||||
- STATUSTEXT rate-limit: at most 1 per source-label transition AND at most 1 per second per severity (defensive against pathological transition spam).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk: pymavlink GPS_INPUT field semantics differ across ArduPilot versions** — *Mitigation*: pinned pymavlink version + integration test against the targeted ArduPilot Plane firmware (verified in IT-3 SITL).
|
||||
- **Risk: STATUSTEXT flood under rapid source-label transitions** — *Mitigation*: AC-4 verifies one-per-transition; secondary 1 Hz cap as a safety net.
|
||||
- **Risk: NAMED_VALUE_FLOAT name truncation** — *Mitigation*: `"src_lbl"` is 7 chars (within MAVLink 10-char limit).
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: AP outbound external-position emission with provenance side-channel.
|
||||
- **Production code**: real pymavlink encode + send; real WgsConverter + CovarianceProjector usage; real STATUSTEXT rate-limit.
|
||||
- **Unacceptable substitutes**: a fake `GPS_INPUT` builder that doesn't go through pymavlink (defeats AC-1 wire-level fidelity).
|
||||
|
||||
## Contract
|
||||
|
||||
Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — `emit_external_position`, `emit_status_text` for AP; Invariants 3, 4, 5, 6, 8.
|
||||
@@ -1,101 +0,0 @@
|
||||
# C8 Msp2InavAdapter — outbound MSP2_SENSOR_GPS
|
||||
|
||||
**Task**: AZ-394_c8_inav_outbound
|
||||
**Name**: C8 `Msp2InavAdapter` outbound — `MSP2_SENSOR_GPS` 5 Hz (D-C8-8 = (b))
|
||||
**Description**: Implement the `Msp2InavAdapter.emit_external_position(EstimatorOutput)` body: encode `EstimatorOutput` into an MSP2 `MSP2_SENSOR_GPS` frame (lat/lon/alt × 1e7 in int32 per MSP2 convention; `hPosAccuracy` in mm from the injected `CovarianceProjector.to_inav_h_pos_accuracy_mm`; ground-speed/heading from C5 velocity sub-vector if present); write to the iNav UART via YAMSPy + INAV-Toolkit. Side-channel: emit `STATUSTEXT` (severity=INFO, "src=<label>") on `source_label` transition (rate-limited) over the SECONDARY MAVLink telemetry channel (iNav supports MAVLink for telemetry but not for primary positioning per RESTRICT-COMM-2). Body of `emit_status_text(msg, severity)` writes to the secondary MAVLink channel. `open(...)` rejects any non-None `signing_key` per Invariant 2 (RESTRICT-COMM-2). `request_source_set_switch` raises `SourceSetSwitchNotSupportedError` (iNav has no equivalent). Smoothed-output guard (Invariant 6). SPD-violation propagates from projector (per Invariant 4). Single-writer-thread invariant (Invariant 8). Sequence-number management for MSP2 frames maintained internally.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-390 (Protocol + DTOs + errors), AZ-392 (`CovarianceProjector` helper), AZ-279 (`WgsConverter`), AZ-273 (FDR), AZ-263, AZ-269, AZ-266, AZ-272 (FDR record schema)
|
||||
**Component**: c8_fc_adapter (epic AZ-261 / E-C8)
|
||||
**Tracker**: AZ-394
|
||||
**Epic**: AZ-261 (E-C8)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariants 2, 3, 4, 6, 8, 9.
|
||||
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 3 External API (MSP2), § 5 implementation details.
|
||||
- `_docs/02_document/architecture.md` — § 5 RESTRICT-COMM-2 (iNav signing-asymmetry).
|
||||
|
||||
## Problem
|
||||
|
||||
Without this task, iNav-targeted flights cannot run: no `MSP2_SENSOR_GPS` is emitted to the iNav EKF; the system is AP-only. RESTRICT-COMM-2 documents iNav as a supported FC for the program; this task implements the wire surface.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py` — `Msp2InavAdapter` class implementing `FcAdapter`.
|
||||
- Constructor: `__init__(self, config, wgs_converter, covariance_projector, fdr_client, clock)`.
|
||||
- Body of `emit_external_position` — encode + emit `MSP2_SENSOR_GPS` via YAMSPy.
|
||||
- Body of `emit_status_text` — emit STATUSTEXT on the secondary MAVLink channel.
|
||||
- Body of `open(...)` — open MSP2 UART; raise `FcAdapterConfigError` on `signing_key != None` (RESTRICT-COMM-2).
|
||||
- Body of `close()` — close UART connection.
|
||||
- `request_source_set_switch` raises `SourceSetSwitchNotSupportedError`.
|
||||
- Internal sequence-number counter for MSP2 frames.
|
||||
- INFO log on first `MSP2_SENSOR_GPS` emit: `kind="c8.inav.first_emit"`.
|
||||
- DEBUG log per emit: `kind="c8.inav.emit"` with `{frame_seq, h_pos_accuracy_mm, source_label}`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- `Msp2InavAdapter` class implementing `FcAdapter`.
|
||||
- `MSP2_SENSOR_GPS` encoding from `EstimatorOutput`.
|
||||
- STATUSTEXT side-channel via secondary MAVLink (transitions only, rate-limited).
|
||||
- `emit_status_text` body.
|
||||
- `open` / `close` body.
|
||||
- Signing-key rejection (Invariant 2).
|
||||
- `request_source_set_switch` not-supported.
|
||||
- iNav signing-asymmetry assertion (Invariant 9): NEVER set the MAVLink2 signed-flag on the side-channel.
|
||||
- Unit tests: encoding fidelity (decode via INAV-Toolkit reference), signing-key rejection, signing-asymmetry assertion (capture wire bytes, no signed-flag), source-set-switch unsupported, smoothed-output rejected.
|
||||
|
||||
### Excluded
|
||||
- AP outbound — owned by AP task.
|
||||
- GCS adapter — owned by GCS task.
|
||||
- Inbound subscription — owned by inbound task.
|
||||
- C8-IT/PT/ST tests — deferred to E-BBT.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: MSP2_SENSOR_GPS field fidelity** — emit a known `EstimatorOutput`; decode the wire bytes via INAV-Toolkit reference decoder; assert lat/lon/alt match `WgsConverter` output × 1e7; `hPosAccuracy` matches `CovarianceProjector.to_inav_h_pos_accuracy_mm(cov_6x6)`.
|
||||
|
||||
**AC-2: MSP2_SENSOR_GPS every frame** — drive 100 frames; assert 100 MSP2 frames on the wire with monotonically incrementing sequence numbers.
|
||||
|
||||
**AC-3: STATUSTEXT secondary channel transition** — drive 100 frames with `source_label` toggling every 10 frames; assert exactly 10 STATUSTEXT on the secondary MAVLink channel; never on the primary MSP2 channel.
|
||||
|
||||
**AC-4: Signing key rejection (Invariant 2)** — `open(port, signing_key=b"...")` → `FcAdapterConfigError("iNav does not support MAVLink signing per RESTRICT-COMM-2")`.
|
||||
|
||||
**AC-5: Signing-asymmetry assertion (Invariant 9)** — capture every byte the iNav adapter writes to the secondary MAVLink channel for 60 s; assert no frame has the MAVLink2 signed-flag set. (This is the unit-level form of C8-IT-08.)
|
||||
|
||||
**AC-6: Source-set-switch not-supported** — `request_source_set_switch()` → `SourceSetSwitchNotSupportedError("iNav: no MAV_CMD_SET_EKF_SOURCE_SET equivalent")`.
|
||||
|
||||
**AC-7: Smoothed output rejected** — `output.smoothed=True` → `FcEmitError` (parallel to AC-5 of the AP task).
|
||||
|
||||
**AC-8: Non-SPD covariance rejected** — propagated from projector → `FcEmitError`.
|
||||
|
||||
**AC-9: Single-writer thread** — second-thread emit raises `RuntimeError`.
|
||||
|
||||
**AC-10: First emit logged once** — `kind="c8.inav.first_emit"` INFO log exactly once per `open(...)` lifetime.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- `emit_external_position` p95 ≤ 5 ms (C8-PT-01 budget; iNav side mirrors AP side).
|
||||
|
||||
## Constraints
|
||||
|
||||
- YAMSPy + INAV-Toolkit at the project's pinned versions.
|
||||
- Single-writer thread enforced.
|
||||
- Secondary MAVLink telemetry channel MUST never set the MAVLink2 signed-flag (Invariant 9).
|
||||
- MSP2 wire format requires explicit sequence numbers — counter persisted in adapter instance state for the flight lifetime.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk: YAMSPy + INAV-Toolkit cross-version drift** — *Mitigation*: pinned versions; integration test against the targeted iNav firmware in IT-3 (separate SITL).
|
||||
- **Risk: int32 lat/lon overflow at extreme values** — *Mitigation*: WgsConverter clamps to ±90° latitude / ±180° longitude before scaling; documented.
|
||||
- **Risk: Sequence number wrap-around mid-flight** — *Mitigation*: sequence counter is `uint8` per MSP2; modular increment is the wire convention; no special handling needed.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: iNav outbound external-position emission.
|
||||
- **Production code**: real YAMSPy encode + send; real `CovarianceProjector` usage.
|
||||
- **Unacceptable substitutes**: a `MAVLink_GPS_INPUT_send` against iNav (wrong wire format; defeats AC-1).
|
||||
|
||||
## Contract
|
||||
|
||||
Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — `emit_external_position`, `emit_status_text` for iNav; Invariants 2, 3, 4, 6, 8, 9.
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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 rejection** — `open(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 GC** — *Mitigation*: 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 traceback** — *Mitigation*: 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.
|
||||
Reference in New Issue
Block a user