From f6f050233108e952dcd3d19c544105e8b060f0cc Mon Sep 17 00:00:00 2001 From: Nate Gay Date: Mon, 3 Aug 2026 14:39:15 +0200 Subject: [PATCH] feat(TcSecurityDeframer): expose active key SPIs and fingerprints Adds GET_ACTIVE_KEYS, which logs an ActiveKeyInfo(spi, fingerprint) event per active key-store slot so an operator who has lost track of what was provisioned can identify the slots without knowing the key material. The fingerprint is the first 4 bytes of SHA-256(key), hex-encoded, and the raw key never leaves the board. Bypass-allowlisted in ProvesRouter, like PROVISION_KEY/GET_SEQ_NUM, since an operator without the key cannot send an authenticated command to ask. Closes #488. --- .../Components/ProvesRouter/Bypasser.cpp | 3 + .../TcSecurityDeframer/Authenticator.cpp | 25 ++++++++ .../TcSecurityDeframer/Authenticator.hpp | 9 +++ .../TcSecurityDeframer/TcSecurityDeframer.cpp | 23 +++++++ .../TcSecurityDeframer/TcSecurityDeframer.fpp | 13 ++++ .../TcSecurityDeframer/TcSecurityDeframer.hpp | 5 ++ .../Components/TcSecurityDeframer/Types.hpp | 4 ++ .../Components/TcSecurityDeframer/docs/sdd.md | 11 +++- .../test/int/security_active_keys_test.py | 62 +++++++++++++++++++ .../unit-tests/test_ProvesRouter_Bypasser.cpp | 15 +++++ .../test_TcSecurityDeframer_Authenticator.cpp | 41 ++++++++++++ README.md | 2 + docs-site/components/TcSecurityDeframer.md | 11 +++- 13 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 PROVESFlightControllerReference/test/int/security_active_keys_test.py diff --git a/PROVESFlightControllerReference/Components/ProvesRouter/Bypasser.cpp b/PROVESFlightControllerReference/Components/ProvesRouter/Bypasser.cpp index 7fe1a4d3..95c1bdeb 100644 --- a/PROVESFlightControllerReference/Components/ProvesRouter/Bypasser.cpp +++ b/PROVESFlightControllerReference/Components/ProvesRouter/Bypasser.cpp @@ -47,6 +47,9 @@ static constexpr uint32_t kBypassOpCodes[] = { 0x2100B002, //!< ComCcsdsUart.tcSecurityDeframer.PROVISION_KEY 0x2200B002, //!< ComCcsdsLora.tcSecurityDeframer.PROVISION_KEY 0x2300B002, //!< ComCcsdsSband.tcSecurityDeframer.PROVISION_KEY + 0x2100B005, //!< ComCcsdsUart.tcSecurityDeframer.GET_ACTIVE_KEYS + 0x2200B005, //!< ComCcsdsLora.tcSecurityDeframer.GET_ACTIVE_KEYS + 0x2300B005, //!< ComCcsdsSband.tcSecurityDeframer.GET_ACTIVE_KEYS 0x10065000, //!< ReferenceDeployment.amateurRadio.TELL_JOKE }; diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.cpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.cpp index 5bbcbdc4..b1b7ce7c 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.cpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.cpp @@ -96,6 +96,31 @@ int32_t destroyHmacKey(uint32_t keyId) { return psa_destroy_key(keyId); } +bool computeKeyFingerprint(const uint8_t (&keyBytes)[Ccsds355_0_B_2::kTCSecurityTrailer], + char (&hexOut)[kKeyFingerprintHexLength + 1]) { + const psa_status_t initStatus = psa_crypto_init(); + if (initStatus != PSA_SUCCESS) { + return false; + } + + uint8_t hash[PSA_HASH_LENGTH(PSA_ALG_SHA_256)]; + size_t hashLength = 0; + const psa_status_t hashStatus = + psa_hash_compute(PSA_ALG_SHA_256, keyBytes, Ccsds355_0_B_2::kTCSecurityTrailer, hash, sizeof hash, &hashLength); + if (hashStatus != PSA_SUCCESS || hashLength < kKeyFingerprintBytes) { + return false; + } + + static constexpr char kHexDigits[] = "0123456789abcdef"; + for (size_t i = 0; i < kKeyFingerprintBytes; i++) { + hexOut[i * 2] = kHexDigits[hash[i] >> 4]; + hexOut[i * 2 + 1] = kHexDigits[hash[i] & 0x0F]; + } + hexOut[kKeyFingerprintHexLength] = '\0'; + + return true; +} + PacketAuthenticator::AuthenticationResult authenticatePacket(const uint8_t* dataBuffer, size_t dataSize, const Mac& hmac, diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.hpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.hpp index 2d1859e1..c92dcda0 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.hpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.hpp @@ -63,6 +63,15 @@ PacketAuthenticator::KeyImportResult importHmacKeyBytes( int32_t destroyHmacKey(uint32_t keyId //!< The PSA key ID to destroy ); +//! Compute a non-reversible fingerprint of a raw HMAC key for ground-facing display: the first +//! kKeyFingerprintBytes bytes of SHA-256(key), hex-encoded. Lets an operator who has lost track of +//! what was provisioned identify a key slot without ever exposing the key itself. +//! Returns true on success and fills `hexOut` with a NUL-terminated lowercase hex string. +bool computeKeyFingerprint( + const uint8_t (&keyBytes)[Ccsds355_0_B_2::kTCSecurityTrailer], //!< The raw key bytes to fingerprint + char (&hexOut)[kKeyFingerprintHexLength + 1] //!< The hex-encoded fingerprint, NUL-terminated +); + //! Check the validity of the packet HMAC PacketAuthenticator::AuthenticationResult authenticatePacket( const uint8_t* buffer, //!< The packet data buffer diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.cpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.cpp index a75f6d64..f02e0e2c 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.cpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.cpp @@ -483,6 +483,29 @@ void TcSecurityDeframer ::REMOVE_KEY_cmdHandler(FwOpcodeType opCode, U32 cmdSeq, this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); } +void TcSecurityDeframer ::GET_ACTIVE_KEYS_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { + Os::ScopeLock lock(keyStoreLock()); + + bool anyActive = false; + for (U32 i = 0; i < AuthKeyStore::SIZE; i++) { + if (!this->m_keyStore[i].get_valid()) { + continue; + } + anyActive = true; + + char fingerprintHex[kKeyFingerprintHexLength + 1]; + if (computeKeyFingerprint(this->m_keyStore[i].get_key(), fingerprintHex)) { + this->log_ACTIVITY_HI_ActiveKeyInfo(this->m_keyStore[i].get_spi(), Fw::LogStringArg(fingerprintHex)); + } + } + + if (!anyActive) { + this->log_ACTIVITY_HI_NoActiveKeys(); + } + + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); +} + // ---------------------------------------------------------------------- // Public helper methods // ---------------------------------------------------------------------- diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.fpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.fpp index 5d4fe39b..41d8509c 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.fpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.fpp @@ -66,6 +66,12 @@ module Components { @ would drop the key count below 1 sync command REMOVE_KEY(spi: U16) + @ Command to report the active SPI(s) and a non-reversible key fingerprint for each, so an + @ operator who has lost track of what was provisioned can identify the slots without knowing + @ the key material. Bypass-allowlisted, like GET_SEQ_NUM, so it stays reachable from a link + @ whose key is unknown. Emits one ActiveKeyInfo event per active slot + sync command GET_ACTIVE_KEYS() + ### Telemetry ### @ Telemetry for the current sequence number, updated on each successfully authenticated packet @@ -124,6 +130,13 @@ module Components { @ KeyRemoveFailed indicates REMOVE_KEY was rejected or failed event KeyRemoveFailed(status: KeyStoreProvisionStatus) severity warning high id 15 format "Key remove failed: {}" throttle 2 + @ ActiveKeyInfo reports one active key slot in response to GET_ACTIVE_KEYS: its SPI and a + @ truncated SHA-256 fingerprint of the key, never the key itself + event ActiveKeyInfo(spi: U16, fingerprint: string size 9) severity activity high id 18 format "Active key: SPI={}, fingerprint={}" + + @ NoActiveKeys indicates GET_ACTIVE_KEYS found no active keys in the store + event NoActiveKeys() severity activity high id 19 format "No active keys in the key store" + ### Parameters ### @ Parameter for the sequence numbers window size, used to prevent replay attacks. The window allows no reuse of previous sequence numbers but allows for new sequence numbers to be accepted within the window size diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.hpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.hpp index 0c09263d..405c5ada 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.hpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/TcSecurityDeframer.hpp @@ -95,6 +95,11 @@ class TcSecurityDeframer final : public TcSecurityDeframerComponentBase { U16 spi //!< The SPI to remove ) override; + //! Handler implementation for command GET_ACTIVE_KEYS + void GET_ACTIVE_KEYS_cmdHandler(FwOpcodeType opCode, //!< The opcode + U32 cmdSeq //!< The command sequence number + ) override; + public: // ---------------------------------------------------------------------- // Public helper methods diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Types.hpp b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Types.hpp index 28c797da..62e696b7 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/Types.hpp +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/Types.hpp @@ -16,6 +16,10 @@ using Mac = std::array; //!< The M constexpr size_t kMaxActiveKeys = 2; //!< Max simultaneously active auth keys (mirrors AuthKeyStore::SIZE) +constexpr size_t kKeyFingerprintBytes = 4; //!< Bytes of SHA-256(key) exposed to ground as a key fingerprint +constexpr size_t kKeyFingerprintHexLength = + kKeyFingerprintBytes * 2; //!< Length of the hex-encoded key fingerprint string + //! A single active-key SPI slot. Mirrors the FPP-generated AuthKeySlot's `valid`/`spi` fields //! without depending on the FPP/F Prime type, so Validator.cpp can stay pure C++. struct ActiveSpiSlot { diff --git a/PROVESFlightControllerReference/Components/TcSecurityDeframer/docs/sdd.md b/PROVESFlightControllerReference/Components/TcSecurityDeframer/docs/sdd.md index 5343ec95..e0d0131e 100644 --- a/PROVESFlightControllerReference/Components/TcSecurityDeframer/docs/sdd.md +++ b/PROVESFlightControllerReference/Components/TcSecurityDeframer/docs/sdd.md @@ -24,6 +24,8 @@ The store is shared across all `TcSecurityDeframer` instances (UART/LoRa/Sband): Three commands manage the store (see [Commands](#commands)): `PROVISION_KEY` (bootstrap, only while the store is empty), `ADD_KEY` (rotation, fails at 2 active keys), and `REMOVE_KEY` (rotation, fails at 1 remaining key). All three re-read the store from flash first, then re-import it into PSA and update `ActiveKeyCount` telemetry on success. +A fourth command, `GET_ACTIVE_KEYS`, lets an operator who has lost track of what was provisioned identify the active slot(s) without knowing the key material: it logs one `ActiveKeyInfo` event per valid slot, reporting the SPI alongside a fingerprint of the key — the first 4 bytes of SHA-256(key), hex-encoded (`computeKeyFingerprint` in `Authenticator.cpp`) — never the key itself. Like `PROVISION_KEY`, it is bypass-allowlisted in `ProvesRouter`, since an operator who does not know the key cannot send an authenticated command to ask. + All three also refuse to act (`StoreUnreadable`) unless the store's contents are *known*, because otherwise they would be acting on a guess. "Known" cannot be decided from the read status. On the Zephyr target `ZephyrFile::open` discards `fs_open`'s errno and reports `OTHER_ERROR` for every failure, so `Os::File::DOESNT_EXIST` is unreachable on flight hardware even though it is the normal result for a missing file on the POSIX host. Gating on the status therefore made a factory-fresh (or `/keys`-erased) board refuse `PROVISION_KEY` forever: keyless *and* unprovisionable, i.e. total command loss. Instead `probeKeyStore()` interrogates the filesystem — `fs_stat` on the store file for a positive absence answer, plus `fs_statvfs` on the mount point to prove `/keys` is actually mounted — and feeds the pure predicates in `Components::KeyStore` (`Types.hpp`). Both signals are required: Zephyr returns `-ENOENT` for an unmounted mount point exactly as it does for a missing file, so absence alone proves nothing. For `ADD_KEY`/`REMOVE_KEY` that prevents a read-modify-write from persisting a stale guess over the real store. For `PROVISION_KEY` it is a security property: the command is bypass-allowlisted so it works on a keyless board, so trust-on-first-use must be gated on *proof* that the store is empty. Treating an unreadable store as keyless would let anyone in radio range induce a read failure and install their own key while a valid one still sits on flash. @@ -52,6 +54,7 @@ class TcSecurityDeframer { -PROVISION_KEY_cmdHandler(opCode, cmdSeq, spi, key) -ADD_KEY_cmdHandler(opCode, cmdSeq, spi, key) -REMOVE_KEY_cmdHandler(opCode, cmdSeq, spi) + -GET_ACTIVE_KEYS_cmdHandler(opCode, cmdSeq) -readSequenceNumber(value) -writeSequenceNumber(value) -loadKeyStore() @@ -82,6 +85,7 @@ class PacketAuthenticator { +importHmacKeyBytes(keyBytes, keyId) KeyImportResult +destroyHmacKey(keyId) int32_t +authenticatePacket(buffer, size, mac, keyId) AuthenticationResult + +computeKeyFingerprint(keyBytes, hexOut) bool } class TCSecurityHeader { @@ -184,6 +188,8 @@ Routed/bypassed/rejected packet counts are telemetered by ProvesRouter, which ow | KeyAddFailed | Warning High (throttle 2) | status: KeyStoreProvisionStatus | Logged when ADD_KEY fails (store full, duplicate SPI, unreadable store, bad hex key, write failure, or PSA import failure). Format: "Key add failed: {}" | | KeyRemoved | Activity High | spi: U16 | Logged by REMOVE_KEY on success. Format: "Key removed for SPI={}" | | KeyRemoveFailed | Warning High (throttle 2) | status: KeyStoreProvisionStatus | Logged when REMOVE_KEY fails (last remaining key, SPI not found, unreadable store, write failure, or PSA import failure). Format: "Key remove failed: {}" | +| ActiveKeyInfo | Activity High | spi: U16, fingerprint: string | Logged by GET_ACTIVE_KEYS once per active slot: the SPI and a truncated-SHA-256 fingerprint of the key, never the key itself. Format: "Active key: SPI={}, fingerprint={}" | +| NoActiveKeys | Activity High | None | Logged by GET_ACTIVE_KEYS when the store holds no active keys. Format: "No active keys in the key store" | ## Commands @@ -194,6 +200,7 @@ Routed/bypassed/rejected packet counts are telemetered by ProvesRouter, which ow | PROVISION_KEY | Sync | spi: U16, key: string | Bootstrap: writes the first key slot and imports it into PSA. Only succeeds while the store is empty; bypass-allowlisted in ProvesRouter so it works on a keyless board (KeyProvisioned/KeyProvisionFailed events). | | ADD_KEY | Sync | spi: U16, key: string | Rotation: adds a key to an empty slot. Requires an authenticated frame (not bypass-allowlisted); fails if 2 keys are already active (KeyAdded/KeyAddFailed events). | | REMOVE_KEY | Sync | spi: U16 | Rotation: invalidates the slot matching `spi`. Requires an authenticated frame (not bypass-allowlisted); fails if it would drop the active key count below 1 (KeyRemoved/KeyRemoveFailed events). | +| GET_ACTIVE_KEYS | Sync | None | Reports the active SPI(s) and a non-reversible key fingerprint for each (one ActiveKeyInfo event per active slot, or NoActiveKeys if the store is empty). Bypass-allowlisted in ProvesRouter, like GET_SEQ_NUM. | ## Unit Tests @@ -204,7 +211,7 @@ TcSecurityDeframer helper functionality is covered by unit tests in PROVESFlight | test_TcSecurityDeframer_Parser.cpp | Valid parse path plus parse failures for SPI, sequence number, and MAC size checks. | | test_TcSecurityDeframer_Validator.cpp | SPI validation against the active `ActiveSpiSlots` set (single slot, second slot, no valid slots), out-of-window and replayed sequence numbers, window boundary, and wraparound handling. | | test_TcSecurityDeframer_KeyStorePolicy.cpp | The key store admission predicates (`Components::KeyStore::storeStateIsKnown` / `storeIsProvisionable`) over the exhaustive mount x store-probe x active-key-count matrix: a cold board on a live mount is provisionable; an unreadable store never is; an absent store on an unproven mount never is; and a regression test documenting why a gate written over `Os::File::Status` diverges between host and target. | -| test_TcSecurityDeframer_Authenticator.cpp | `parseHexKey` (valid upper/lowercase, null, wrong length, non-hex characters), key import via hex and raw bytes, `destroyHmacKey`, successful MAC verification, and failed verification with corrupted MAC, corrupted data, or a destroyed key. | +| test_TcSecurityDeframer_Authenticator.cpp | `parseHexKey` (valid upper/lowercase, null, wrong length, non-hex characters), key import via hex and raw bytes, `destroyHmacKey`, successful MAC verification, failed verification with corrupted MAC, corrupted data, or a destroyed key, and `computeKeyFingerprint` (deterministic for the same key, differs for different keys, never echoes the key's own hex encoding). | These cover only the pure-function layer (Parser/Validator/Authenticator; no F Prime or Zephyr dependency). The key store mutation rules enforced in the command handlers (`PROVISION_KEY`/`ADD_KEY`/`REMOVE_KEY` — provision-only-when-empty, add fails at 2, remove fails at 1) are F-Prime-component-dependent and are not covered here; see the commented-out `register_fprime_ut` block in `CMakeLists.txt` for a future on-target/component test pass. @@ -242,6 +249,7 @@ The authentication key is no longer compiled into the image, so there is nothing | AUTH005-A | The component shall not mark packets as authenticated where the computed MAC does not match the security trailer MAC. | Unit Test | | AUTH006 | For any parseable frame, the component shall remove the Security Header and Security Trailer and forward the remaining packet data with the verification result recorded in the frame context. | Inspection, Integration Test | | AUTH007 | The component shall provide a command and telemetry channel to report the current sequence number to enable ground station synchronization. | Inspection, Integration Test | +| AUTH008 | The component shall provide a command to report the active SPI(s) and a non-reversible fingerprint of each associated key, without ever exposing the key itself. | Unit Test, Integration Test | Opcode-based bypass policy (formerly AUTH002) is owned by ProvesRouter; see its SDD. @@ -252,3 +260,4 @@ Opcode-based bypass policy (formerly AUTH002) is owned by ProvesRouter; see its | 2025-11-26 | Initial design. | | 2026-07-17 | Renamed to TcSecurityDeframer, refactor to discrete responsibilities: Authenticator, Parser, Validator. Pass-through interface between TcDeframer and SpacePacketDeframer; verification result carried in frame context; policy enforcement moved to ProvesRouter. | | 2026-07-23 | Moved the authentication key off the compiled-in image onto a littlefs key store on internal flash, alongside the sequence-number file (issue #220). Added PROVISION_KEY/ADD_KEY/REMOVE_KEY commands supporting up to 2 active keys; SPI validation now checks the active key store instead of a hard-coded SPI 0. | +| 2026-08-03 | Added GET_ACTIVE_KEYS (issue #488): reports the active SPI(s) and a truncated-SHA-256 fingerprint of each key via the new ActiveKeyInfo/NoActiveKeys events, so an operator who has lost track of what was provisioned can identify slots without knowing the key material. Bypass-allowlisted like PROVISION_KEY/GET_SEQ_NUM. | diff --git a/PROVESFlightControllerReference/test/int/security_active_keys_test.py b/PROVESFlightControllerReference/test/int/security_active_keys_test.py new file mode 100644 index 00000000..2f475ac1 --- /dev/null +++ b/PROVESFlightControllerReference/test/int/security_active_keys_test.py @@ -0,0 +1,62 @@ +""" +security_active_keys_test.py: + +Exercises GET_ACTIVE_KEYS on TcSecurityDeframer: an operator who has lost track +of what was provisioned can query the active SPI(s) and a non-reversible +fingerprint of each key, without ever learning the key itself. Runs after +provision_key_test (alphabetically: "security" sorts after "provision_key"), +so the store is guaranteed to hold at least the CI key at SPI 0. +""" + +import os +import re + +import pytest +from common import proves_send_and_assert_command +from fprime_gds.common.data_types.event_data import EventData +from fprime_gds.common.testing_fw.api import IntegrationTestAPI + +# 4-byte truncated SHA-256 fingerprint, hex-encoded: 8 lowercase hex characters +_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{8}$") + + +def _deframer_for(request: pytest.FixtureRequest) -> str: + link = request.config.getoption("--sync-deframer", default=None) + if link is None: + link = ( + "lora" + if request.config.getoption("--with-radio", default=False) + else "uart" + ) + return { + "uart": "ComCcsdsUart.tcSecurityDeframer", + "lora": "ComCcsdsLora.tcSecurityDeframer", + }[link] + + +def test_get_active_keys_reports_provisioned_spi( + fprime_test_api: IntegrationTestAPI, start_gds, request: pytest.FixtureRequest +): + """GET_ACTIVE_KEYS reports SPI=0 (provisioned by provision_key_test) with a fingerprint, + and never echoes the raw key bytes.""" + deframer = _deframer_for(request) + + proves_send_and_assert_command(fprime_test_api, f"{deframer}.GET_ACTIVE_KEYS") + + evt: EventData = fprime_test_api.assert_event( + f"{deframer}.ActiveKeyInfo", timeout=5 + ) + spi = evt.args[0].val + fingerprint = str(evt.args[1].val) + + assert spi == 0, f"Expected SPI=0 (provisioned by provision_key_test), got {spi}" + assert _FINGERPRINT_RE.match(fingerprint), ( + f"Fingerprint {fingerprint!r} is not an 8-character lowercase hex string" + ) + + key = os.environ.get("PROVES_AUTH_KEY") + if key: + assert fingerprint.lower() not in key.lower(), ( + "Fingerprint must never contain the raw key material" + ) + assert key.lower() not in fingerprint.lower() diff --git a/PROVESFlightControllerReference/test/unit-tests/test_ProvesRouter_Bypasser.cpp b/PROVESFlightControllerReference/test/unit-tests/test_ProvesRouter_Bypasser.cpp index 9cd2426e..d27bc65d 100644 --- a/PROVESFlightControllerReference/test/unit-tests/test_ProvesRouter_Bypasser.cpp +++ b/PROVESFlightControllerReference/test/unit-tests/test_ProvesRouter_Bypasser.cpp @@ -40,6 +40,21 @@ TEST(BypasserTest, AllowsSbandGetSeqNum) { EXPECT_TRUE(bypassPacket(buf.data(), buf.size())); } +TEST(BypasserTest, AllowsUartGetActiveKeys) { + auto buf = makePacket(0x2100B005); + EXPECT_TRUE(bypassPacket(buf.data(), buf.size())); +} + +TEST(BypasserTest, AllowsLoraGetActiveKeys) { + auto buf = makePacket(0x2200B005); + EXPECT_TRUE(bypassPacket(buf.data(), buf.size())); +} + +TEST(BypasserTest, AllowsSbandGetActiveKeys) { + auto buf = makePacket(0x2300B005); + EXPECT_TRUE(bypassPacket(buf.data(), buf.size())); +} + TEST(BypasserTest, AllowsTellJoke) { auto buf = makePacket(0x10065000); EXPECT_TRUE(bypassPacket(buf.data(), buf.size())); diff --git a/PROVESFlightControllerReference/test/unit-tests/test_TcSecurityDeframer_Authenticator.cpp b/PROVESFlightControllerReference/test/unit-tests/test_TcSecurityDeframer_Authenticator.cpp index 598aff24..60c11f44 100644 --- a/PROVESFlightControllerReference/test/unit-tests/test_TcSecurityDeframer_Authenticator.cpp +++ b/PROVESFlightControllerReference/test/unit-tests/test_TcSecurityDeframer_Authenticator.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include "PROVESFlightControllerReference/Components/TcSecurityDeframer/Authenticator.hpp" @@ -133,6 +135,45 @@ TEST(PacketAuthenticatorTest, ShortBuffer) { EXPECT_EQ(res.psaStatus, PSA_ERROR_INVALID_ARGUMENT); } +TEST(KeyFingerprintTest, DeterministicForSameKey) { + uint8_t keyBytes[Ccsds355_0_B_2::kTCSecurityTrailer]; + ASSERT_TRUE(parseHexKey(kTestKeyHex, keyBytes)); + + char first[kKeyFingerprintHexLength + 1]; + char second[kKeyFingerprintHexLength + 1]; + ASSERT_TRUE(computeKeyFingerprint(keyBytes, first)); + ASSERT_TRUE(computeKeyFingerprint(keyBytes, second)); + + EXPECT_STREQ(first, second); + EXPECT_EQ(std::strlen(first), kKeyFingerprintHexLength); +} + +TEST(KeyFingerprintTest, DiffersForDifferentKeys) { + uint8_t keyA[Ccsds355_0_B_2::kTCSecurityTrailer]; + uint8_t keyB[Ccsds355_0_B_2::kTCSecurityTrailer]; + ASSERT_TRUE(parseHexKey(kTestKeyHex, keyA)); + ASSERT_TRUE(parseHexKey("00000000000000000000000000000000", keyB)); + + char fingerprintA[kKeyFingerprintHexLength + 1]; + char fingerprintB[kKeyFingerprintHexLength + 1]; + ASSERT_TRUE(computeKeyFingerprint(keyA, fingerprintA)); + ASSERT_TRUE(computeKeyFingerprint(keyB, fingerprintB)); + + EXPECT_STRNE(fingerprintA, fingerprintB); +} + +TEST(KeyFingerprintTest, NeverContainsKeyBytesAsSubstring) { + // The fingerprint must be non-reversible: at minimum, it should never simply echo the key's own + // hex encoding back to the caller. + uint8_t keyBytes[Ccsds355_0_B_2::kTCSecurityTrailer]; + ASSERT_TRUE(parseHexKey(kTestKeyHex, keyBytes)); + + char fingerprint[kKeyFingerprintHexLength + 1]; + ASSERT_TRUE(computeKeyFingerprint(keyBytes, fingerprint)); + + EXPECT_EQ(std::string(kTestKeyHex).find(fingerprint), std::string::npos); +} + TEST(PacketAuthenticatorTest, MinimumSizeBuffer) { uint32_t keyId = importTestKey(); diff --git a/README.md b/README.md index d3338976..19bb6016 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ The board holds up to two active keys so a rotation never leaves you locked out. Doing step 3 before step 2 works too, but leaves nothing to fall back on if the new key turns out to be wrong. `REMOVE_KEY` refuses to remove the last remaining key. +Lost track of what's provisioned on a board? Send `GET_ACTIVE_KEYS()` (bypass-allowlisted, like `PROVISION_KEY`, so it works even without the key). It logs one `ActiveKeyInfo(spi, fingerprint)` event per active slot — the SPI plus a non-reversible fingerprint of the key, never the key itself — so you can identify which key is which without knowing the key material. + ## Running Integration Tests First, start GDS with: diff --git a/docs-site/components/TcSecurityDeframer.md b/docs-site/components/TcSecurityDeframer.md index 5343ec95..e0d0131e 100644 --- a/docs-site/components/TcSecurityDeframer.md +++ b/docs-site/components/TcSecurityDeframer.md @@ -24,6 +24,8 @@ The store is shared across all `TcSecurityDeframer` instances (UART/LoRa/Sband): Three commands manage the store (see [Commands](#commands)): `PROVISION_KEY` (bootstrap, only while the store is empty), `ADD_KEY` (rotation, fails at 2 active keys), and `REMOVE_KEY` (rotation, fails at 1 remaining key). All three re-read the store from flash first, then re-import it into PSA and update `ActiveKeyCount` telemetry on success. +A fourth command, `GET_ACTIVE_KEYS`, lets an operator who has lost track of what was provisioned identify the active slot(s) without knowing the key material: it logs one `ActiveKeyInfo` event per valid slot, reporting the SPI alongside a fingerprint of the key — the first 4 bytes of SHA-256(key), hex-encoded (`computeKeyFingerprint` in `Authenticator.cpp`) — never the key itself. Like `PROVISION_KEY`, it is bypass-allowlisted in `ProvesRouter`, since an operator who does not know the key cannot send an authenticated command to ask. + All three also refuse to act (`StoreUnreadable`) unless the store's contents are *known*, because otherwise they would be acting on a guess. "Known" cannot be decided from the read status. On the Zephyr target `ZephyrFile::open` discards `fs_open`'s errno and reports `OTHER_ERROR` for every failure, so `Os::File::DOESNT_EXIST` is unreachable on flight hardware even though it is the normal result for a missing file on the POSIX host. Gating on the status therefore made a factory-fresh (or `/keys`-erased) board refuse `PROVISION_KEY` forever: keyless *and* unprovisionable, i.e. total command loss. Instead `probeKeyStore()` interrogates the filesystem — `fs_stat` on the store file for a positive absence answer, plus `fs_statvfs` on the mount point to prove `/keys` is actually mounted — and feeds the pure predicates in `Components::KeyStore` (`Types.hpp`). Both signals are required: Zephyr returns `-ENOENT` for an unmounted mount point exactly as it does for a missing file, so absence alone proves nothing. For `ADD_KEY`/`REMOVE_KEY` that prevents a read-modify-write from persisting a stale guess over the real store. For `PROVISION_KEY` it is a security property: the command is bypass-allowlisted so it works on a keyless board, so trust-on-first-use must be gated on *proof* that the store is empty. Treating an unreadable store as keyless would let anyone in radio range induce a read failure and install their own key while a valid one still sits on flash. @@ -52,6 +54,7 @@ class TcSecurityDeframer { -PROVISION_KEY_cmdHandler(opCode, cmdSeq, spi, key) -ADD_KEY_cmdHandler(opCode, cmdSeq, spi, key) -REMOVE_KEY_cmdHandler(opCode, cmdSeq, spi) + -GET_ACTIVE_KEYS_cmdHandler(opCode, cmdSeq) -readSequenceNumber(value) -writeSequenceNumber(value) -loadKeyStore() @@ -82,6 +85,7 @@ class PacketAuthenticator { +importHmacKeyBytes(keyBytes, keyId) KeyImportResult +destroyHmacKey(keyId) int32_t +authenticatePacket(buffer, size, mac, keyId) AuthenticationResult + +computeKeyFingerprint(keyBytes, hexOut) bool } class TCSecurityHeader { @@ -184,6 +188,8 @@ Routed/bypassed/rejected packet counts are telemetered by ProvesRouter, which ow | KeyAddFailed | Warning High (throttle 2) | status: KeyStoreProvisionStatus | Logged when ADD_KEY fails (store full, duplicate SPI, unreadable store, bad hex key, write failure, or PSA import failure). Format: "Key add failed: {}" | | KeyRemoved | Activity High | spi: U16 | Logged by REMOVE_KEY on success. Format: "Key removed for SPI={}" | | KeyRemoveFailed | Warning High (throttle 2) | status: KeyStoreProvisionStatus | Logged when REMOVE_KEY fails (last remaining key, SPI not found, unreadable store, write failure, or PSA import failure). Format: "Key remove failed: {}" | +| ActiveKeyInfo | Activity High | spi: U16, fingerprint: string | Logged by GET_ACTIVE_KEYS once per active slot: the SPI and a truncated-SHA-256 fingerprint of the key, never the key itself. Format: "Active key: SPI={}, fingerprint={}" | +| NoActiveKeys | Activity High | None | Logged by GET_ACTIVE_KEYS when the store holds no active keys. Format: "No active keys in the key store" | ## Commands @@ -194,6 +200,7 @@ Routed/bypassed/rejected packet counts are telemetered by ProvesRouter, which ow | PROVISION_KEY | Sync | spi: U16, key: string | Bootstrap: writes the first key slot and imports it into PSA. Only succeeds while the store is empty; bypass-allowlisted in ProvesRouter so it works on a keyless board (KeyProvisioned/KeyProvisionFailed events). | | ADD_KEY | Sync | spi: U16, key: string | Rotation: adds a key to an empty slot. Requires an authenticated frame (not bypass-allowlisted); fails if 2 keys are already active (KeyAdded/KeyAddFailed events). | | REMOVE_KEY | Sync | spi: U16 | Rotation: invalidates the slot matching `spi`. Requires an authenticated frame (not bypass-allowlisted); fails if it would drop the active key count below 1 (KeyRemoved/KeyRemoveFailed events). | +| GET_ACTIVE_KEYS | Sync | None | Reports the active SPI(s) and a non-reversible key fingerprint for each (one ActiveKeyInfo event per active slot, or NoActiveKeys if the store is empty). Bypass-allowlisted in ProvesRouter, like GET_SEQ_NUM. | ## Unit Tests @@ -204,7 +211,7 @@ TcSecurityDeframer helper functionality is covered by unit tests in PROVESFlight | test_TcSecurityDeframer_Parser.cpp | Valid parse path plus parse failures for SPI, sequence number, and MAC size checks. | | test_TcSecurityDeframer_Validator.cpp | SPI validation against the active `ActiveSpiSlots` set (single slot, second slot, no valid slots), out-of-window and replayed sequence numbers, window boundary, and wraparound handling. | | test_TcSecurityDeframer_KeyStorePolicy.cpp | The key store admission predicates (`Components::KeyStore::storeStateIsKnown` / `storeIsProvisionable`) over the exhaustive mount x store-probe x active-key-count matrix: a cold board on a live mount is provisionable; an unreadable store never is; an absent store on an unproven mount never is; and a regression test documenting why a gate written over `Os::File::Status` diverges between host and target. | -| test_TcSecurityDeframer_Authenticator.cpp | `parseHexKey` (valid upper/lowercase, null, wrong length, non-hex characters), key import via hex and raw bytes, `destroyHmacKey`, successful MAC verification, and failed verification with corrupted MAC, corrupted data, or a destroyed key. | +| test_TcSecurityDeframer_Authenticator.cpp | `parseHexKey` (valid upper/lowercase, null, wrong length, non-hex characters), key import via hex and raw bytes, `destroyHmacKey`, successful MAC verification, failed verification with corrupted MAC, corrupted data, or a destroyed key, and `computeKeyFingerprint` (deterministic for the same key, differs for different keys, never echoes the key's own hex encoding). | These cover only the pure-function layer (Parser/Validator/Authenticator; no F Prime or Zephyr dependency). The key store mutation rules enforced in the command handlers (`PROVISION_KEY`/`ADD_KEY`/`REMOVE_KEY` — provision-only-when-empty, add fails at 2, remove fails at 1) are F-Prime-component-dependent and are not covered here; see the commented-out `register_fprime_ut` block in `CMakeLists.txt` for a future on-target/component test pass. @@ -242,6 +249,7 @@ The authentication key is no longer compiled into the image, so there is nothing | AUTH005-A | The component shall not mark packets as authenticated where the computed MAC does not match the security trailer MAC. | Unit Test | | AUTH006 | For any parseable frame, the component shall remove the Security Header and Security Trailer and forward the remaining packet data with the verification result recorded in the frame context. | Inspection, Integration Test | | AUTH007 | The component shall provide a command and telemetry channel to report the current sequence number to enable ground station synchronization. | Inspection, Integration Test | +| AUTH008 | The component shall provide a command to report the active SPI(s) and a non-reversible fingerprint of each associated key, without ever exposing the key itself. | Unit Test, Integration Test | Opcode-based bypass policy (formerly AUTH002) is owned by ProvesRouter; see its SDD. @@ -252,3 +260,4 @@ Opcode-based bypass policy (formerly AUTH002) is owned by ProvesRouter; see its | 2025-11-26 | Initial design. | | 2026-07-17 | Renamed to TcSecurityDeframer, refactor to discrete responsibilities: Authenticator, Parser, Validator. Pass-through interface between TcDeframer and SpacePacketDeframer; verification result carried in frame context; policy enforcement moved to ProvesRouter. | | 2026-07-23 | Moved the authentication key off the compiled-in image onto a littlefs key store on internal flash, alongside the sequence-number file (issue #220). Added PROVISION_KEY/ADD_KEY/REMOVE_KEY commands supporting up to 2 active keys; SPI validation now checks the active key store instead of a hard-coded SPI 0. | +| 2026-08-03 | Added GET_ACTIVE_KEYS (issue #488): reports the active SPI(s) and a truncated-SHA-256 fingerprint of each key via the new ActiveKeyInfo/NoActiveKeys events, so an operator who has lost track of what was provisioned can identify slots without knowing the key material. Bypass-allowlisted like PROVISION_KEY/GET_SEQ_NUM. |