Batch 38 (cycle 1) lands the two upload-side prerequisites the upcoming AZ-319 TileUploader needs to authenticate per-flight sessions against the parent suite's D-PROJ-2 ingest contract. AZ-317 FlightStateGate: - confirm_on_ground() defence-in-depth gate atop ADR-004 process isolation; fail-closed for UNKNOWN, IN_FLIGHT, TAKING_OFF, LANDING, and source-failure (mapped to UNKNOWN with original exception preserved on __cause__). - ERROR log on refusal, INFO log on pass, single source call per invocation (no polling, no retry). AZ-318 PerFlightKeyManager: - Per-flight ephemeral Ed25519 keypair via the project-pinned cryptography library; sign(payload) -> 64-byte Ed25519 signature. - Best-effort zeroisation of a project-controlled bytearray mirror on end_session; OpenSSL-side buffer freed via dropped reference. - __del__ safety net with WARN log if end_session was missed. - start_session emits FDR kind=c11.upload.session.key.public so the safety officer can correlate flights with key fingerprints. - record_signature_rejection emits FDR + ERROR log on parent-suite ingest rejection (security-critical, never silently dropped). Shared C11 plumbing: - TileManagerError parent + 3 subclasses (FlightStateNotOnGroundError, SessionNotActiveError, SignatureRejectedError envelope). - FlightStateSignal (str, Enum) and PublicKeyFingerprint DTOs. - FlightStateSource Protocol on c11_tile_manager.interface. - runtime_root.c11_factory factories for both new services. - Two new FDR kinds registered in fdr_client.records central KNOWN_PAYLOAD_KEYS; AZ-272 schema-roundtrip fixtures added in lockstep so the central test stays green. Tests: 26 new + 2 fixture additions; full suite 1384 passed, 80 skipped (documented Docker / Tier-2 / CUDA gates). Code review: PASS_WITH_WARNINGS — 2 Low findings documented in _docs/03_implementation/reviews/batch_38_review.md (dev-host vs operator-workstation perf bound; spec text named StrEnum but project pins Python 3.10). Co-authored-by: Cursor <cursoragent@cursor.com>
16 KiB
C11 Per-Flight Signing Key — Generation + Sign + Zeroise
Task: AZ-318_c11_signing_key
Name: C11 Per-Flight Signing Key
Description: Implement the per-flight ephemeral signing key used by TileUploader to authenticate each uploaded tile against the parent suite's D-PROJ-2 ingest contract. PerFlightKeyManager generates one fresh Ed25519 keypair per flight at upload-session start, signs the multipart payload per tile, and zeroises the secret-key buffer in memory after the session completes (success OR failure). The public key is recorded in the FDR (kind="c11.upload.session.key.public") so the safety officer can later correlate which key signed which tiles. On SignatureRejectedError from satellite-provider, the manager emits an FDR alert (kind="c11.upload.signature_rejected") — security-critical event, never silently dropped. Uses the project-pinned cryptography library; no custom crypto.
Complexity: 3 points
Dependencies: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-273_fdr_client_ringbuf
Component: c11_tilemanager (epic AZ-251 / E-C11)
Tracker: AZ-318
Epic: AZ-251 (E-C11)
Document Dependencies
_docs/02_document/components/12_c11_tilemanager/description.md— § 3.2 D-PROJ-2 contract sketch (signature requirement), § 5SignatureRejectedError, § 7 R09 key-compromise mitigation._docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md—kind="c11.upload.session.key.public"andkind="c11.upload.signature_rejected"envelopes._docs/02_document/contracts/shared_logging/log_record_schema.md— INFO/ERROR log shapes for key lifecycle events._docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md— D-PROJ-2 design task #1 (parent-suite ingest contract), specifically thesignaturefield requirement.
Problem
Without a per-flight ephemeral signing key:
- D-PROJ-2 contract sketch demands every uploaded tile carry a
signaturefield; without it,satellite-provider's ingest endpoint will reject every payload. - The R09 risk (key compromise) is unmitigated — a single static API key would compromise every flight's uploads on first leak; per-flight keys bound the blast radius to one flight.
- The "ingest-side voting layer" (D-PROJ-2 design task #2) cannot trust uploaded tiles without a way to associate each tile with its source flight; the public key is the binding.
- AC-NEW-7 (cache-poisoning safety budget) loses one of its layers — the voting layer relies on per-flight keys to detect collusion (multiple compromised companions colluding becomes detectable when their key fingerprints differ from the safety officer's pre-flight enrolment record).
- Per
description.md§ 5:SignatureRejectedErroris a security-critical event; without a structured handler, it would either crash the upload run or be silently caught. - The C11-ST-03 security test (key zeroised after upload) has no implementation to verify against — without zeroisation, the secret-key bytes remain in heap memory long after the upload completes, increasing exfil window.
This task delivers the key lifecycle. It does NOT plumb the key into the upload payload (TileUploader task does that); it provides sign(payload) as the boundary.
Outcome
- A
PerFlightKeyManagerclass atsrc/gps_denied_onboard/components/c11_tilemanager/signing_key.py:- Constructor:
__init__(self, *, fdr_client: FdrClient, logger: Logger). No state at construction time. start_session(flight_id: uuid.UUID) -> PublicKeyFingerprint:- Generates a fresh Ed25519 keypair via
cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate(). - Stores the private key in
self._private_key(instance state, not module-level). - Computes
public_key_pem = private_key.public_key().public_bytes(...). - Computes
fingerprint = sha256(public_key_pem).hex()[:16]. - Emits FDR
kind="c11.upload.session.key.public"with{flight_id, public_key_pem, fingerprint, generated_at_iso}. - Emits INFO log
kind="c11.upload.session.key.generated"with{flight_id, fingerprint}(NEVER the private key). - Returns
PublicKeyFingerprint(flight_id, public_key_pem, fingerprint, generated_at).
- Generates a fresh Ed25519 keypair via
sign(payload: bytes) -> bytes:- Raises
SessionNotActiveErrorifself._private_key is None. - Returns
self._private_key.sign(payload)(Ed25519 signature is 64 bytes). - No log emission per call (would flood at upload throughput).
- Raises
end_session() -> None:- If
self._private_key is None, no-op. - Calls
self._zeroise_private_key()(overwrites the secret-key bytes with zeros viacryptography's key-deletion guidance, then setsself._private_key = None). - Emits INFO log
kind="c11.upload.session.key.zeroised".
- If
record_signature_rejection(flight_id, tile_id) -> None:- Emits FDR
kind="c11.upload.signature_rejected"with{flight_id, tile_id, fingerprint, observed_at_iso}. - Emits ERROR log with the same payload.
- Emits FDR
- Constructor:
PublicKeyFingerprintDTO atsrc/gps_denied_onboard/components/c11_tilemanager/_types.py—@dataclass(frozen=True)with the four fields above.SessionNotActiveErrordefined insrc/gps_denied_onboard/components/c11_tilemanager/errors.py— subclassesTileManagerError. (SignatureRejectedErroris also defined here, but raised byTileUploaderafter parsing the ingest response, NOT by this task.)- The TileUploader task (separate) calls:
start_session(flight_id)once per upload run.sign(payload)once per tile.record_signature_rejection(...)on each per-tile rejection from the ingest response.end_session()in afinallyblock guaranteeing zeroisation on success or failure.
- The composition root constructs
PerFlightKeyManagerand injects it intoTileUploader. Factory:build_per_flight_key_manager(fdr_client, logger) -> PerFlightKeyManager. - A
__del__safety net callsend_session()if it was never explicitly called, with a WARN log noting the leak. This is a belt-and-braces guarantee, not the primary control.
Scope
Included
PerFlightKeyManagerclass (4 public methods +__del__safety net).PublicKeyFingerprintDTO.SessionNotActiveErrordefinition.- Ed25519 keypair generation using the project-pinned
cryptographylibrary. - Best-effort zeroisation of the secret-key buffer (via
cryptographylibrary's recommended deletion path; documented as "best-effort" because Python heap zeroisation cannot be guaranteed without ctypes-level pinning). - FDR emission on session start (public key) and on signature rejection.
- INFO log on session lifecycle events; ERROR log on signature rejection.
- Composition-root factory.
Excluded
- The TileUploader integration (signing into multipart payloads) — owned by the TileUploader task.
- Pre-flight key enrolment workflow (the safety officer's record of expected per-flight public keys) — owned by C12 operator tooling.
- HSM / TPM-backed key storage — out of scope this cycle; the assumption is that the operator workstation's process is trusted enough for ephemeral in-memory keys, with zeroisation as the residual hygiene.
- Mid-session key rotation — one key per session; rotation requires
end_session+start_session. - Key persistence between processes — the key is in-memory ONLY; an upload session must complete in one process lifetime.
- The
SignatureRejectedErrorclass itself is defined here but raised by TileUploader.
Acceptance Criteria
AC-1: start_session generates a fresh keypair and emits FDR
Given a fresh PerFlightKeyManager
When start_session(flight_id) is called
Then the manager holds a non-None _private_key; PublicKeyFingerprint is returned with a 16-char hex fingerprint; ONE FDR kind="c11.upload.session.key.public" is emitted with the public-key PEM; ONE INFO log without the private key
AC-2: Two consecutive sessions produce different keys
Given start_session(F1) followed by end_session() followed by start_session(F2)
When fingerprints are compared
Then fingerprint_F1 != fingerprint_F2 (cryptographically distinct keys); two FDR records are emitted, one per session
AC-3: sign returns 64-byte Ed25519 signature
Given an active session
When sign(b"hello world") is called
Then a 64-byte signature is returned; the signature verifies against the session's public key (verifiable via Ed25519PublicKey.verify)
AC-4: sign before start_session raises
Given a fresh PerFlightKeyManager
When sign(b"...") is called without prior start_session
Then SessionNotActiveError is raised; no signature is computed
AC-5: sign after end_session raises
Given start_session(F) then end_session()
When sign(b"...") is called
Then SessionNotActiveError is raised
AC-6: end_session zeroises and emits log
Given an active session
When end_session() is called
Then self._private_key is None; the underlying secret-key buffer is overwritten with zeros (verifiable via ctypes.string_at against the buffer address captured pre-zeroise); ONE INFO log kind="c11.upload.session.key.zeroised"
AC-7: __del__ safety net zeroises if end_session was missed
Given an active session whose owner is garbage-collected without calling end_session
When the GC runs __del__
Then end_session() runs implicitly; ONE WARN log kind="c11.upload.session.key.zeroised_via_finalizer"; the buffer is zeroised
AC-8: record_signature_rejection emits FDR + ERROR log
Given an active session and a tile_id
When record_signature_rejection(flight_id, tile_id) is called
Then ONE FDR kind="c11.upload.signature_rejected" is emitted with {flight_id, tile_id, fingerprint, observed_at_iso}; ONE ERROR log with the same payload
AC-9: Private key never logged anywhere Given the full session lifecycle When all log records and all FDR records are captured Then the private-key PEM does NOT appear in ANY record (verifiable via byte search across the captured stream)
AC-10: end_session is idempotent
Given an active session
When end_session() is called twice in a row
Then the second call is a no-op; no exception is raised; no second INFO log is emitted
Non-Functional Requirements
Performance
signp99 ≤ 200 µs on the operator workstation (Ed25519 is fast; the bottleneck is the upload network, not signing).start_session≤ 5 ms (Ed25519 keygen is sub-millisecond; FDR emission + log emission dominate).
Compatibility
cryptographylibrary at the project-pinned version. Verify before adding; do NOT bump unilaterally.- Ed25519 is available in
cryptography.hazmat.primitives.asymmetric.ed25519since 2.6 — the project pin must be ≥ 2.6.
Reliability
- The manager guarantees zeroisation on
end_sessionAND on__del__— both paths converge through the same_zeroise_private_keyhelper. - The Python heap layer cannot guarantee bit-perfect zeroisation (objects may be relocated by the GC); this is documented. The mitigation is: keep the key buffer's lifetime as short as possible (one upload session) and rely on the OS-level memory protections (no swap on the operator workstation per RESTRICT-OPS-1).
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | start_session then capture FDR + log |
Public PEM in FDR; fingerprint 16 hex chars; private key not in log |
| AC-2 | Two sessions back-to-back | Different fingerprints |
| AC-3 | Sign + verify roundtrip | 64-byte signature; verifies against public key |
| AC-4 | sign without start_session |
SessionNotActiveError |
| AC-5 | sign after end_session |
SessionNotActiveError |
| AC-6 | end_session and inspect zeroised buffer |
Buffer is all zeros; log emitted |
| AC-7 | Drop reference + force GC | __del__ runs end_session; WARN log |
| AC-8 | record_signature_rejection |
FDR + ERROR log with all fields |
| AC-9 | Capture all logs/FDR for a full session; byte-search private PEM | Not present |
| AC-10 | end_session twice |
Second call is no-op; no second log |
| NFR-perf-sign | Microbench sign × 100k |
p99 ≤ 200 µs |
| NFR-reliability-fingerprint-uniqueness | 1000 sessions with unique flight_ids | All 1000 fingerprints distinct (collision-resistant) |
Constraints
- The signing algorithm is Ed25519; no per-task choice (the parent suite's D-PROJ-2 contract requires Ed25519 per the leftover file's design).
- The secret-key never leaves the manager —
sign(payload) -> bytesis the only method that uses it; consumers do NOT touch the private key. - The public key is logged AND FDR'd (it is public by definition); the private key is NOT logged anywhere — code-review treats any private-key reference outside
signing_key.pyas aSecurityfinding (Critical). - This task pins to the project's existing
cryptographyversion. If the version doesn't supportEd25519PrivateKey.generate(), ASK the user before bumping (percoderule.mdc"verify the API actually exists in the pinned version"). __del__is a safety net, NOT the primary contract — consumers MUST callend_session()explicitly. Code-review treats reliance on__del__as aReliabilityfinding.
Risks & Mitigation
Risk 1: Python heap zeroisation is not bit-perfect
- Risk: The
cryptographylibrary returns the private key as a Python object; freeing the object's memory does not guarantee zeroisation (the GC may relocate objects). - Mitigation: Documented as "best-effort"; the operator workstation runs no-swap (RESTRICT-OPS-1); the key lifetime is bounded to one upload session (typically minutes); the residual exfil window is minimised. A future task could add ctypes-level pinning if the threat model tightens.
Risk 2: __del__ doesn't run when the process is killed (SIGKILL)
- Risk: A SIGKILL during an active session leaves the key buffer in heap memory until the OS reclaims the process pages.
- Mitigation: Documented; the OS-level mitigation is process termination → memory pages reclaimed; on Linux with no swap, the bytes never hit disk. No software mitigation is feasible inside the killed process.
Risk 3: FDR ringbuffer overrun loses the public-key record
- Risk: Under FDR backpressure (AZ-274 overrun), the
kind="c11.upload.session.key.public"record might be dropped — the safety officer cannot correlate the upload with a key fingerprint later. - Mitigation: AZ-273's ringbuffer is sized per
_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md; this task adds NO new pressure but is documented as critical-priority. Mid-flight FDR loss is already an AC-NEW-1 concern; this task surfaces the dependency.
Risk 4: cryptography library API drift across pins
- Risk: A minor
cryptographybump renamesEd25519PrivateKey.generate()or changes its signature. - Mitigation: The task verifies the API against the pinned version (per
coderule.mdc); the pin is recorded inrequirements.txt; a wrapper isolates the library to this single class.
Risk 5: Replay attack — captured signed payloads re-uploaded by an attacker
- Risk: An MITM captures a valid
(payload, signature)pair and re-uploads tosatellite-provider's ingest endpoint. - Mitigation: Out of scope for this task — the parent suite's ingest endpoint owns nonce / timestamp validation per the D-PROJ-2 design. C11 includes
capture_timestampin the signed payload (per the leftover file's contract sketch); the parent suite rejects timestamps outside its acceptance window. This task does NOT add a separate nonce.
Runtime Completeness
- Named capability: per-flight ephemeral signing key per D-PROJ-2 contract, R09 mitigation, AC-NEW-7 voting-layer enabler (description.md § 7, leftover file design task #1).
- Production code that must exist: real
PerFlightKeyManagerwith real Ed25519 keypair generation viacryptography, realsign, real best-effort zeroisation, real FDR emission for public-key + signature-rejection events, real__del__safety net. - Allowed external stubs: tests MAY use a fake
FdrClient(already provided by AZ-275 fake_fdr_sink) and a fakeLogger; production wiring uses the real AZ-273 ringbuffer + AZ-266 logger. - Unacceptable substitutes: a hardcoded shared key reused across flights (defeats R09 mitigation); a pseudo-random "key" generated from
random.getrandbitsinstead ofcryptography's CSPRNG (rolling our own crypto is rejected percoderule.mdc); skippingend_sessionzeroisation (loses C11-ST-03 test surface); logging the private key for "debugging" (Critical Security finding).