[AZ-317] [AZ-318] C11 upload-side: flight-state gate + per-flight key

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 05:48:52 +03:00
parent ca0430a44d
commit cde237e236
16 changed files with 1936 additions and 8 deletions
@@ -1,8 +1,46 @@
"""C11 Tile Manager component — Public API."""
"""C11 Tile Manager component — Public API.
Re-exports the Protocol surface (``TileDownloader``, ``TileUploader``,
``FlightStateSource``), the upload-side services that have landed
(``FlightStateGate`` from AZ-317, ``PerFlightKeyManager`` from
AZ-318), the C11 internal DTOs / enums, and the C11 error family.
The download-side concrete impl (``HttpTileDownloader``) ships in
AZ-316; the upload-side concrete impl (``TileUploader``) ships in
AZ-319 — both will be added to ``__all__`` then.
"""
from gps_denied_onboard.components.c11_tile_manager._types import (
FlightStateSignal,
PublicKeyFingerprint,
)
from gps_denied_onboard.components.c11_tile_manager.errors import (
FlightStateNotOnGroundError,
SessionNotActiveError,
SignatureRejectedError,
TileManagerError,
)
from gps_denied_onboard.components.c11_tile_manager.flight_state_gate import (
FlightStateGate,
)
from gps_denied_onboard.components.c11_tile_manager.interface import (
FlightStateSource,
TileDownloader,
TileUploader,
)
from gps_denied_onboard.components.c11_tile_manager.signing_key import (
PerFlightKeyManager,
)
__all__ = ["TileDownloader", "TileUploader"]
__all__ = [
"FlightStateGate",
"FlightStateNotOnGroundError",
"FlightStateSignal",
"FlightStateSource",
"PerFlightKeyManager",
"PublicKeyFingerprint",
"SessionNotActiveError",
"SignatureRejectedError",
"TileDownloader",
"TileManagerError",
"TileUploader",
]
@@ -0,0 +1,54 @@
"""C11 internal DTOs (AZ-317, AZ-318).
* :class:`FlightStateSignal` — the five flight-state signals consumed by
the upload-side flight-state gate (AZ-317).
* :class:`PublicKeyFingerprint` — the per-flight Ed25519 keypair
fingerprint envelope returned by :meth:`PerFlightKeyManager.start_session`
(AZ-318).
Internal to the component — composition-root code reaches these via the
``c11_tile_manager`` package re-exports; consumers outside C11 use the
public API surface.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from uuid import UUID
__all__ = [
"FlightStateSignal",
"PublicKeyFingerprint",
]
class FlightStateSignal(str, Enum):
"""Five flight-state signals C11's upload-side gate accepts.
Only :attr:`ON_GROUND` permits an upload; every other value is
fail-closed by the AZ-317 gate (AC-2..AC-5).
"""
ON_GROUND = "on_ground"
TAKING_OFF = "taking_off"
IN_FLIGHT = "in_flight"
LANDING = "landing"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class PublicKeyFingerprint:
"""Public-key envelope returned by :meth:`PerFlightKeyManager.start_session`.
The 16-character ``fingerprint`` is the first 16 hex chars of the
SHA-256 of the PEM-encoded public key — the value the safety officer
pre-enrols and the parent-suite ingest endpoint correlates uploads
against (D-PROJ-2 contract sketch).
"""
flight_id: UUID
public_key_pem: bytes
fingerprint: str
generated_at: datetime
@@ -0,0 +1,79 @@
"""C11 TileManager error family (AZ-317, AZ-318, plus reserved AZ-319 envelope).
Rooted at :class:`TileManagerError`. The parent is declared here (rather
than alongside the AZ-316 ``TileDownloader``) so the upload-side tasks
landing first do not need to wait on a downloader-only file. AZ-316
(``HttpTileDownloader``) will add its download-side errors as further
subclasses without re-declaring the parent.
* :class:`FlightStateNotOnGroundError` (AZ-317) — defence-in-depth
refusal when the flight controller reports anything other than
``ON_GROUND`` at upload entry.
* :class:`SessionNotActiveError` (AZ-318) — :meth:`PerFlightKeyManager.sign`
/ :meth:`record_signature_rejection` called outside an active session.
* :class:`SignatureRejectedError` (AZ-318 envelope) — defined here for
the upload-side error family; raised by ``TileUploader`` (separate
task) after parsing the ``satellite-provider`` ingest response.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from gps_denied_onboard.components.c11_tile_manager._types import (
FlightStateSignal,
)
__all__ = [
"FlightStateNotOnGroundError",
"SessionNotActiveError",
"SignatureRejectedError",
"TileManagerError",
]
class TileManagerError(Exception):
"""Base class for the C11 TileManager error family."""
class FlightStateNotOnGroundError(TileManagerError):
"""Upload was attempted when the flight controller is not on ground.
Carries the observed :class:`FlightStateSignal` and the diagnostic
``observed_at`` timestamp. The original source exception (if the
refusal was caused by a :class:`FlightStateSource` failure mapped
to ``UNKNOWN`` per AC-5) is preserved on ``__cause__``.
"""
def __init__(
self,
observed: FlightStateSignal,
observed_at: datetime,
) -> None:
self.observed: FlightStateSignal = observed
self.observed_at: datetime = observed_at
super().__init__(
f"Upload refused: flight state is {observed.name}"
)
class SessionNotActiveError(TileManagerError):
""":meth:`PerFlightKeyManager.sign` called without a live session.
Raised when ``sign`` (or ``record_signature_rejection``) is invoked
before :meth:`start_session` or after :meth:`end_session` has
zeroised the secret-key buffer.
"""
class SignatureRejectedError(TileManagerError):
"""``satellite-provider`` ingest endpoint rejected the per-flight signature.
Defined alongside the C11 upload error family so the AZ-319
``TileUploader`` raises the canonical type. The upload-side
handler calls :meth:`PerFlightKeyManager.record_signature_rejection`
to surface the FDR + ERROR log envelope per AZ-318 AC-8 before
re-raising this exception to the operator-tooling layer.
"""
@@ -0,0 +1,129 @@
"""C11 ``FlightStateGate`` (AZ-317).
Defence-in-depth ON_GROUND gate for the upload entry point. The
primary control is ADR-004 process-level isolation — the airborne
binary has the entire ``c11_tile_manager`` source tree excluded at
build time. The gate is the runtime backstop: if the operator
workstation triggers an upload while the flight controller reports
anything other than ``ON_GROUND``, the gate refuses with
:class:`FlightStateNotOnGroundError`.
Fail-closed by design — ``UNKNOWN``, transition states, and source
failures all block. AZ-317 acceptance criteria spell out the full
matrix.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from gps_denied_onboard.components.c11_tile_manager._types import (
FlightStateSignal,
)
from gps_denied_onboard.components.c11_tile_manager.errors import (
FlightStateNotOnGroundError,
)
from gps_denied_onboard.components.c11_tile_manager.interface import (
FlightStateSource,
)
__all__ = ["FlightStateGate"]
_LOG_KIND_PASS = "c11.upload.flight_state_confirmed"
_LOG_KIND_REFUSED = "c11.upload.refused.flight_state"
_COMPONENT = "c11_tile_manager.flight_state_gate"
def _utcnow_second_precision() -> datetime:
"""Diagnostic UTC timestamp truncated to seconds (AC-7)."""
return datetime.now(timezone.utc).replace(microsecond=0)
class FlightStateGate:
"""Single-shot ON_GROUND check called by the upload entry point.
The gate is constructed once at composition time and called once
per :meth:`upload_pending_tiles` invocation by the AZ-319
:class:`TileUploader`. It performs no caching, no retries, and no
polling — :meth:`current_flight_state` is invoked exactly once per
:meth:`confirm_on_ground` call (AC-8).
"""
def __init__(
self,
*,
source: FlightStateSource,
logger: logging.Logger,
) -> None:
self._source = source
self._logger = logger
def confirm_on_ground(self) -> FlightStateSignal:
"""Return :attr:`FlightStateSignal.ON_GROUND` or raise.
Behaviour matrix:
* ``ON_GROUND`` → return + INFO log (AC-1).
* ``IN_FLIGHT`` / ``TAKING_OFF`` / ``LANDING`` / ``UNKNOWN`` →
raise :class:`FlightStateNotOnGroundError` + ERROR log
(AC-2..AC-4).
* Source raises → map to ``UNKNOWN`` + chain the original
exception via ``__cause__`` + ERROR log carrying the
original message (AC-5).
"""
try:
observed = self._source.current_flight_state()
except Exception as exc:
observed_at = _utcnow_second_precision()
error = FlightStateNotOnGroundError(
observed=FlightStateSignal.UNKNOWN,
observed_at=observed_at,
)
error.__cause__ = exc
self._logger.error(
"Upload refused: flight state source failed",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_REFUSED,
"kv": {
"observed": FlightStateSignal.UNKNOWN.value,
"observed_at_iso": observed_at.isoformat(),
"source_error": str(exc),
},
},
)
raise error
observed_at = _utcnow_second_precision()
if observed is FlightStateSignal.ON_GROUND:
self._logger.info(
"Upload entry permitted: flight state is ON_GROUND",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_PASS,
"kv": {
"observed": observed.value,
"observed_at_iso": observed_at.isoformat(),
},
},
)
return observed
self._logger.error(
f"Upload refused: flight state is {observed.name}",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_REFUSED,
"kv": {
"observed": observed.value,
"observed_at_iso": observed_at.isoformat(),
},
},
)
raise FlightStateNotOnGroundError(
observed=observed,
observed_at=observed_at,
)
@@ -1,16 +1,34 @@
"""C11 `TileDownloader` + `TileUploader` Protocols.
"""C11 ``TileDownloader`` + ``TileUploader`` + ``FlightStateSource`` Protocols.
Operator-side ONLY — excluded from airborne via CMake (`BUILD_C11_TILE_MANAGER=OFF`).
See `_docs/02_document/components/12_c11_tilemanager/`.
* :class:`TileDownloader` — pre-flight download path (AZ-316, pending).
* :class:`TileUploader` — post-landing upload path (AZ-319, pending).
* :class:`FlightStateSource` — thin C11-facing adapter the upload-side
flight-state gate (AZ-317) calls to read "what is the FC saying right
now?". A concrete impl ships with E-C8 (subscribes to the FC adapter's
flight-state stream); composition root wires it via the AZ-507
consumer-side cut pattern (see `_docs/02_document/module-layout.md`
Rule 9). C11 NEVER imports ``components.c8_fc_adapter`` directly.
"""
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from typing import Protocol
from typing import Protocol, runtime_checkable
from gps_denied_onboard._types.tile import TileRecord
from gps_denied_onboard.components.c11_tile_manager._types import (
FlightStateSignal,
)
__all__ = [
"FlightStateSource",
"TileDownloader",
"TileUploader",
]
class TileDownloader(Protocol):
@@ -25,3 +43,18 @@ class TileUploader(Protocol):
"""Post-landing batch upload to the `satellite-provider` ingest endpoint (D-PROJ-2)."""
def upload(self, tiles: Iterable[TileRecord], flight_id: str) -> None: ...
@runtime_checkable
class FlightStateSource(Protocol):
"""Consumer-side cut: "what is the flight controller saying now?".
The AZ-317 :class:`FlightStateGate` calls
:meth:`current_flight_state` once per :meth:`confirm_on_ground`
invocation; no polling, no caching. The concrete impl that
subscribes to MAVLink heartbeats lives in E-C8 and is wrapped by a
composition-root adapter so C11 never imports
``components.c8_fc_adapter``.
"""
def current_flight_state(self) -> FlightStateSignal: ...
@@ -0,0 +1,365 @@
"""C11 ``PerFlightKeyManager`` (AZ-318).
Per-flight ephemeral Ed25519 signing key used by the upload-side
:class:`TileUploader` (AZ-319) to authenticate every uploaded tile
against the parent-suite's D-PROJ-2 ingest contract.
Lifecycle:
1. :meth:`start_session` generates a fresh Ed25519 keypair and emits
the public-key envelope to the FDR (``kind=
"c11.upload.session.key.public"``) so the safety officer can
correlate flights with their signing key.
2. :meth:`sign` returns an Ed25519 signature over the supplied
payload. Steady-state path; no log emission per call (would flood
under upload throughput).
3. :meth:`end_session` zeroes the secret-key buffer best-effort and
drops every Python reference to the underlying
:class:`Ed25519PrivateKey`.
4. :meth:`record_signature_rejection` is the single FDR + ERROR log
surface for ``SignatureRejectedError`` events; the caller (the
AZ-319 ``TileUploader``) invokes it before re-raising the
security-critical exception.
Best-effort zeroisation
-----------------------
``cryptography`` wraps the Ed25519 secret in OpenSSL-side memory the
Python layer cannot reach. The manager ALSO holds a project-controlled
:class:`bytearray` (``_secret_buffer``) that mirrors the same secret
bytes; that buffer is overwritten with zeros on
:meth:`end_session` so the test surface (AC-6) can verify the zeroise
path. The OpenSSL-side buffer is freed when the
:class:`Ed25519PrivateKey` object's refcount drops to zero; the
manager drops its reference inside :meth:`end_session`.
The double-storage trade-off (one Python copy, one OpenSSL copy) is
documented in AZ-318 Risk-1; the residual exfil window is bounded by
the upload session lifetime (typically minutes) and the operator
workstation runs no-swap (RESTRICT-OPS-1).
"""
from __future__ import annotations
import ctypes
import datetime as _dt
import hashlib
import logging
from typing import TYPE_CHECKING
from uuid import UUID
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from gps_denied_onboard.components.c11_tile_manager._types import (
PublicKeyFingerprint,
)
from gps_denied_onboard.components.c11_tile_manager.errors import (
SessionNotActiveError,
)
from gps_denied_onboard.fdr_client import (
CURRENT_SCHEMA_VERSION,
FdrClient,
FdrRecord,
)
if TYPE_CHECKING:
from gps_denied_onboard.clock import Clock
__all__ = ["PerFlightKeyManager"]
_FDR_KIND_KEY_PUBLIC = "c11.upload.session.key.public"
_FDR_KIND_SIGNATURE_REJECTED = "c11.upload.signature_rejected"
_LOG_KIND_KEY_GENERATED = "c11.upload.session.key.generated"
_LOG_KIND_KEY_ZEROISED = "c11.upload.session.key.zeroised"
_LOG_KIND_KEY_ZEROISED_GC = "c11.upload.session.key.zeroised_via_finalizer"
_LOG_KIND_SIGNATURE_REJECTED = "c11.upload.signature_rejected"
_COMPONENT = "c11_tile_manager.signing_key"
_FINGERPRINT_LEN = 16
_ED25519_SECRET_BYTES = 32
def _ts_iso(clock: Clock) -> str:
"""RFC 3339 UTC timestamp from ``clock.time_ns()``."""
seconds, ns = divmod(clock.time_ns(), 1_000_000_000)
dt = _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc)
micros = ns // 1000
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{micros:06d}Z"
def _ts_datetime(clock: Clock) -> _dt.datetime:
"""UTC :class:`datetime` from ``clock.time_ns()`` with microsecond precision."""
seconds, ns = divmod(clock.time_ns(), 1_000_000_000)
return _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc).replace(
microsecond=ns // 1000
)
class PerFlightKeyManager:
"""Per-flight ephemeral Ed25519 signing-key lifecycle manager.
Constructor takes the FDR client and the structured logger. No
cryptographic state at construction time — :meth:`start_session`
materialises it, :meth:`end_session` zeroises it.
"""
def __init__(
self,
*,
fdr_client: FdrClient,
logger: logging.Logger,
clock: Clock,
) -> None:
self._fdr_client = fdr_client
self._logger = logger
self._clock = clock
self._private_key: Ed25519PrivateKey | None = None
self._secret_buffer: bytearray | None = None
self._fingerprint: str | None = None
self._flight_id: UUID | None = None
@property
def is_active(self) -> bool:
"""Test-only introspection: True between :meth:`start_session` and :meth:`end_session`."""
return self._private_key is not None
@property
def secret_buffer_address(self) -> int | None:
"""Test-only introspection: address of the secret bytearray (None if inactive).
Used by the AC-6 test to capture the buffer address pre-zeroise
and read its bytes via :func:`ctypes.string_at` post-zeroise.
Returns None when the manager has no active session — the
bytearray itself MAY still be alive after :meth:`end_session`
so the captured address remains a valid (now zeroed) memory
region for the AC-6 verification, but the public introspection
returns None to mirror "no active key" semantics.
"""
if self._private_key is None or self._secret_buffer is None:
return None
return ctypes.addressof(
(ctypes.c_char * len(self._secret_buffer)).from_buffer(self._secret_buffer)
)
def start_session(self, flight_id: UUID) -> PublicKeyFingerprint:
"""Generate a fresh Ed25519 keypair for ``flight_id``.
Idempotence: starting a new session replaces any prior key
(the manager re-zeroises the prior secret buffer first; the
test path documented under AC-2 expects two distinct
fingerprints across back-to-back sessions). Re-starting an
already-active session is the caller's responsibility — the
manager does not refuse it but the upload-side workflow
treats overlapping sessions as a programming error.
"""
if self._secret_buffer is not None:
self._zeroise_secret_buffer()
self._private_key = None
private_key = Ed25519PrivateKey.generate()
secret_bytes = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
if len(secret_bytes) != _ED25519_SECRET_BYTES:
raise RuntimeError(
f"Ed25519 raw private key must be {_ED25519_SECRET_BYTES} bytes; "
f"got {len(secret_bytes)}"
)
secret_buffer = bytearray(secret_bytes)
public_key_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
fingerprint = hashlib.sha256(public_key_pem).hexdigest()[:_FINGERPRINT_LEN]
generated_at = _ts_datetime(self._clock)
ts_iso = _ts_iso(self._clock)
self._private_key = private_key
self._secret_buffer = secret_buffer
self._fingerprint = fingerprint
self._flight_id = flight_id
self._fdr_client.enqueue(
FdrRecord(
schema_version=CURRENT_SCHEMA_VERSION,
ts=ts_iso,
producer_id=self._fdr_client.producer_id,
kind=_FDR_KIND_KEY_PUBLIC,
payload={
"flight_id": str(flight_id),
"public_key_pem": public_key_pem.decode("ascii"),
"fingerprint": fingerprint,
"generated_at_iso": generated_at.isoformat(),
},
)
)
self._logger.info(
"Per-flight signing key generated",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_KEY_GENERATED,
"kv": {
"flight_id": str(flight_id),
"fingerprint": fingerprint,
},
},
)
return PublicKeyFingerprint(
flight_id=flight_id,
public_key_pem=public_key_pem,
fingerprint=fingerprint,
generated_at=generated_at,
)
def sign(self, payload: bytes) -> bytes:
"""Return an Ed25519 signature over ``payload`` (64 bytes).
Raises :class:`SessionNotActiveError` if called outside a live
session (i.e. before :meth:`start_session` or after
:meth:`end_session`). No log emission — would flood the steady
upload-side path.
"""
if self._private_key is None:
raise SessionNotActiveError(
"PerFlightKeyManager.sign called without an active session"
)
return self._private_key.sign(payload)
def end_session(self) -> None:
"""Zero the secret-key buffer best-effort and drop the live key.
Idempotent: a no-op when no session is active (AC-10). The
caller (the AZ-319 ``TileUploader``) MUST invoke this from a
``finally`` block so the zeroise path runs on success and
failure alike.
"""
if self._private_key is None:
return
self._zeroise_secret_buffer()
self._private_key = None
self._fingerprint = None
flight_id = self._flight_id
self._flight_id = None
self._logger.info(
"Per-flight signing key zeroised",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_KEY_ZEROISED,
"kv": {
"flight_id": None if flight_id is None else str(flight_id),
},
},
)
def record_signature_rejection(
self, flight_id: UUID, tile_id: str
) -> None:
"""Surface an upload-side ``SignatureRejectedError`` to FDR + ERROR log.
Security-critical event; never silently dropped. Emits ONE
FDR (``kind="c11.upload.signature_rejected"``) and ONE ERROR
log carrying the same payload.
"""
if self._private_key is None:
raise SessionNotActiveError(
"PerFlightKeyManager.record_signature_rejection called "
"without an active session"
)
observed_at = _ts_datetime(self._clock)
ts_iso = _ts_iso(self._clock)
payload = {
"flight_id": str(flight_id),
"tile_id": tile_id,
"fingerprint": self._fingerprint or "",
"observed_at_iso": observed_at.isoformat(),
}
self._fdr_client.enqueue(
FdrRecord(
schema_version=CURRENT_SCHEMA_VERSION,
ts=ts_iso,
producer_id=self._fdr_client.producer_id,
kind=_FDR_KIND_SIGNATURE_REJECTED,
payload=payload,
)
)
self._logger.error(
"Per-flight signature rejected by ingest endpoint",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_SIGNATURE_REJECTED,
"kv": payload,
},
)
def __del__(self) -> None:
"""Best-effort safety net: zero on garbage-collection.
Documented in AZ-318 AC-7 / Risk-2 — ``__del__`` is NOT the
primary contract. Callers MUST invoke :meth:`end_session`
explicitly. The finalizer emits a WARN log naming the
zeroise-via-finalizer kind so the operator workflow can
retroactively spot leaks.
Wraps every action in a broad except: Python disallows
exceptions from ``__del__`` and the interpreter's late-shutdown
state can make even basic operations (logging, ctypes) raise.
"""
if self._private_key is None and self._secret_buffer is None:
return
try:
self._zeroise_secret_buffer()
self._private_key = None
try:
self._logger.warning(
"Per-flight signing key zeroised via finalizer",
extra={
"component": _COMPONENT,
"kind": _LOG_KIND_KEY_ZEROISED_GC,
"kv": {
"flight_id": (
None if self._flight_id is None else str(self._flight_id)
),
},
},
)
except Exception:
# Late-shutdown: logger handlers may be torn down. The
# bytearray zeroise above already ran; that is the
# security-relevant action.
pass
except Exception:
pass
def _zeroise_secret_buffer(self) -> None:
"""Overwrite the secret bytearray in-place with zero bytes.
Pure Python ``bytearray[:] = b"\\x00" * len(...)`` is sufficient
for the bytearray we control. The cryptography library's
OpenSSL-side buffer is dropped via ``self._private_key = None``
and freed when refcounts hit zero — outside this method's
reach. We deliberately keep ``self._secret_buffer`` alive
(just zeroed) so the AC-6 test path can re-read the captured
memory address and observe zeros; freeing the bytearray would
let CPython recycle the page and the captured ``id()`` would
point at unrelated memory. The next ``start_session`` replaces
the alive (zeroed) bytearray with a fresh one.
"""
if self._secret_buffer is None:
return
size = len(self._secret_buffer)
self._secret_buffer[:] = b"\x00" * size