Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ----------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ using Mac = std::array<uint8_t, Ccsds355_0_B_2::kTCSecurityTrailer>; //!< 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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. |
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Loading
Loading