mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 11:11:23 +00:00
[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:
@@ -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
|
||||
Reference in New Issue
Block a user