"""AZ-318 ``PerFlightKeyManager`` unit tests. Covers all ten acceptance criteria + NFRs from ``_docs/02_tasks/done/AZ-318_c11_signing_key.md`` (after the batch-38 archive). Uses :class:`FakeFdrSink` for FDR capture, a list-backed log handler for log capture, and a deterministic ``_FixedClock`` for timestamp assertions. """ from __future__ import annotations import ctypes import gc import logging import time from uuid import UUID, uuid4 import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from gps_denied_onboard.components.c11_tile_manager import ( PerFlightKeyManager, PublicKeyFingerprint, SessionNotActiveError, ) from gps_denied_onboard.fdr_client import FdrRecord from gps_denied_onboard.fdr_client.fakes import FakeFdrSink # ---------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------- _PRODUCER_ID = "c11_tile_manager.signing_key" class _FixedClock: """:class:`Clock` impl returning a fixed wall-clock time.""" def __init__(self, time_ns: int = 1_700_000_000_000_000_000) -> None: self._time_ns = time_ns self._mono = 0 def monotonic_ns(self) -> int: self._mono += 1 return self._mono def time_ns(self) -> int: return self._time_ns def sleep_until_ns(self, target_ns: int) -> None: return def _build_manager() -> tuple[PerFlightKeyManager, FakeFdrSink, list[logging.LogRecord]]: fdr = FakeFdrSink(_PRODUCER_ID) records: list[logging.LogRecord] = [] class _ListHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: records.append(record) logger = logging.getLogger(f"test_az318_{id(records)}") logger.handlers.clear() logger.addHandler(_ListHandler()) logger.setLevel(logging.DEBUG) logger.propagate = False manager = PerFlightKeyManager( fdr_client=fdr, logger=logger, clock=_FixedClock(), ) return manager, fdr, records def _kinds(records: list[FdrRecord]) -> list[str]: return [r.kind for r in records] def _log_kinds(records: list[logging.LogRecord]) -> list[str]: return [getattr(r, "kind", None) for r in records] # ---------------------------------------------------------------------- # AC-1: start_session generates fresh keypair, emits FDR + INFO log # ---------------------------------------------------------------------- def test_ac1_start_session_emits_public_key_fdr_and_info_log() -> None: # Arrange manager, fdr, log_records = _build_manager() flight_id = uuid4() # Act fingerprint = manager.start_session(flight_id) # Assert assert isinstance(fingerprint, PublicKeyFingerprint) assert len(fingerprint.fingerprint) == 16 int(fingerprint.fingerprint, 16) assert manager.is_active fdr_records = fdr.records assert _kinds(fdr_records) == ["c11.upload.session.key.public"] payload = fdr_records[0].payload assert payload["flight_id"] == str(flight_id) assert payload["fingerprint"] == fingerprint.fingerprint assert "BEGIN PUBLIC KEY" in payload["public_key_pem"] assert _log_kinds(log_records) == ["c11.upload.session.key.generated"] info_log = log_records[0] assert info_log.levelname == "INFO" assert info_log.kv == { "flight_id": str(flight_id), "fingerprint": fingerprint.fingerprint, } # ---------------------------------------------------------------------- # AC-2: two sessions produce different fingerprints # ---------------------------------------------------------------------- def test_ac2_two_sessions_produce_distinct_fingerprints_and_two_fdr_records() -> None: # Arrange manager, fdr, _ = _build_manager() f1 = uuid4() f2 = uuid4() # Act fp1 = manager.start_session(f1) manager.end_session() fp2 = manager.start_session(f2) # Assert assert fp1.fingerprint != fp2.fingerprint assert _kinds(fdr.records) == [ "c11.upload.session.key.public", "c11.upload.session.key.public", ] # ---------------------------------------------------------------------- # AC-3: sign returns 64-byte Ed25519 signature, verifies against public key # ---------------------------------------------------------------------- def test_ac3_sign_returns_64_byte_signature_that_verifies() -> None: # Arrange manager, _, _ = _build_manager() fingerprint = manager.start_session(uuid4()) payload = b"hello world" # Act sig = manager.sign(payload) # Assert assert isinstance(sig, bytes) assert len(sig) == 64 public_key = serialization.load_pem_public_key(fingerprint.public_key_pem) assert isinstance(public_key, Ed25519PublicKey) public_key.verify(sig, payload) # ---------------------------------------------------------------------- # AC-4: sign before start_session raises # ---------------------------------------------------------------------- def test_ac4_sign_without_session_raises() -> None: # Arrange manager, _, _ = _build_manager() # Act + Assert with pytest.raises(SessionNotActiveError): manager.sign(b"unauthorised") # ---------------------------------------------------------------------- # AC-5: sign after end_session raises # ---------------------------------------------------------------------- def test_ac5_sign_after_end_session_raises() -> None: # Arrange manager, _, _ = _build_manager() manager.start_session(uuid4()) manager.end_session() # Act + Assert with pytest.raises(SessionNotActiveError): manager.sign(b"too late") # ---------------------------------------------------------------------- # AC-6: end_session zeroises the secret buffer # ---------------------------------------------------------------------- def test_ac6_end_session_zeroises_secret_buffer_and_emits_log() -> None: # Arrange manager, _, log_records = _build_manager() manager.start_session(uuid4()) buffer_address = manager.secret_buffer_address assert buffer_address is not None pre_zeroise = ctypes.string_at(buffer_address, 32) assert pre_zeroise != b"\x00" * 32 # Act manager.end_session() post_zeroise = ctypes.string_at(buffer_address, 32) # Assert assert post_zeroise == b"\x00" * 32 assert "c11.upload.session.key.zeroised" in _log_kinds(log_records) assert manager.secret_buffer_address is None assert not manager.is_active # ---------------------------------------------------------------------- # AC-7: __del__ safety net zeroises if end_session was missed # ---------------------------------------------------------------------- def test_ac7_del_safety_net_zeroises_and_emits_warn_log() -> None: # Arrange fdr = FakeFdrSink(_PRODUCER_ID) log_records: list[logging.LogRecord] = [] class _ListHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: log_records.append(record) logger = logging.getLogger("test_az318_del_safety") logger.handlers.clear() logger.addHandler(_ListHandler()) logger.setLevel(logging.DEBUG) logger.propagate = False manager = PerFlightKeyManager( fdr_client=fdr, logger=logger, clock=_FixedClock(), ) manager.start_session(uuid4()) buffer_address = manager.secret_buffer_address assert buffer_address is not None # Act del manager gc.collect() # Assert assert "c11.upload.session.key.zeroised_via_finalizer" in _log_kinds(log_records) # ---------------------------------------------------------------------- # AC-8: record_signature_rejection emits FDR + ERROR log # ---------------------------------------------------------------------- def test_ac8_record_signature_rejection_emits_fdr_and_error_log() -> None: # Arrange manager, fdr, log_records = _build_manager() flight_id = uuid4() manager.start_session(flight_id) tile_id = "tile-z18-50.0-36.0" # Act manager.record_signature_rejection(flight_id, tile_id) # Assert rejection_records = [ r for r in fdr.records if r.kind == "c11.upload.signature_rejected" ] assert len(rejection_records) == 1 payload = rejection_records[0].payload assert payload["flight_id"] == str(flight_id) assert payload["tile_id"] == tile_id assert payload["fingerprint"] assert "observed_at_iso" in payload error_logs = [r for r in log_records if r.levelname == "ERROR"] assert len(error_logs) == 1 assert error_logs[0].kv == payload # ---------------------------------------------------------------------- # AC-9: Private key never appears in any captured stream # ---------------------------------------------------------------------- def test_ac9_private_key_pem_never_appears_in_logs_or_fdr() -> None: # Arrange manager, fdr, log_records = _build_manager() manager.start_session(uuid4()) manager.sign(b"payload-1") manager.record_signature_rejection(uuid4(), "tile-1") manager.end_session() # Act full_stream = b"" for fdr_record in fdr.records: full_stream += repr(fdr_record).encode() for log_record in log_records: full_stream += log_record.getMessage().encode() full_stream += repr(getattr(log_record, "kv", {})).encode() # Assert assert b"BEGIN PRIVATE KEY" not in full_stream assert b"PRIVATE" not in full_stream or b"PUBLIC" in full_stream # ---------------------------------------------------------------------- # AC-10: end_session is idempotent # ---------------------------------------------------------------------- def test_ac10_end_session_idempotent_no_second_log() -> None: # Arrange manager, _, log_records = _build_manager() manager.start_session(uuid4()) manager.end_session() log_count_after_first_end = len( [r for r in log_records if getattr(r, "kind", None) == "c11.upload.session.key.zeroised"] ) # Act manager.end_session() # Assert log_count_after_second_end = len( [r for r in log_records if getattr(r, "kind", None) == "c11.upload.session.key.zeroised"] ) assert log_count_after_second_end == log_count_after_first_end # ---------------------------------------------------------------------- # NFR-perf-sign: microbench p99 ≤ 200 µs # ---------------------------------------------------------------------- def test_nfr_perf_sign_microbench_p99_under_one_ms() -> None: # Arrange # Spec NFR (AZ-318 §Performance): sign p99 ≤ 200 µs on the # operator workstation. The dev-host bound here is intentionally # looser (1 ms) so this test stays portable across CI and laptop # runs; the strict 200 µs budget is verified separately on the # operator workstation Tier-1 host (manual run, not in CI). # See AZ-318 Risk-2 / "Performance" section. manager, _, _ = _build_manager() manager.start_session(uuid4()) payload = b"x" * 256 warmup_iterations = 200 iterations = 2_000 for _ in range(warmup_iterations): manager.sign(payload) # Act samples_ns: list[int] = [] for _ in range(iterations): start = time.perf_counter_ns() manager.sign(payload) samples_ns.append(time.perf_counter_ns() - start) manager.end_session() # Assert samples_ns.sort() p99_ns = samples_ns[int(iterations * 0.99) - 1] assert p99_ns < 1_000_000, ( f"sign p99 latency {p99_ns} ns exceeds dev-host bound of 1 ms " f"(spec NFR is 200 µs on operator workstation)" ) # ---------------------------------------------------------------------- # NFR-reliability-fingerprint-uniqueness: 200 sessions all distinct # ---------------------------------------------------------------------- def test_nfr_reliability_fingerprint_uniqueness_1000_sessions() -> None: # Arrange manager, _, _ = _build_manager() fingerprints: set[str] = set() # Act for _ in range(1000): fp = manager.start_session(uuid4()) fingerprints.add(fp.fingerprint) manager.end_session() # Assert assert len(fingerprints) == 1000 # ---------------------------------------------------------------------- # Defensive: record_signature_rejection without active session raises # ---------------------------------------------------------------------- def test_record_signature_rejection_without_session_raises() -> None: # Arrange manager, _, _ = _build_manager() # Act + Assert with pytest.raises(SessionNotActiveError): manager.record_signature_rejection(uuid4(), "tile-1")