Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
69dcec7
Move HMAC auth key off firmware image onto internal-flash key store
nateinaction Jul 23, 2026
1ff5d8b
debug(ci): capture fault registers after boot to diagnose integration…
nateinaction Jul 23, 2026
78ca10f
debug(ci): fix OpenOCD target-selection syntax in fault-register diag…
nateinaction Jul 23, 2026
0949dfb
debug(ci): query individual registers with explicit poll/settle in fa…
nateinaction Jul 23, 2026
693eb5d
debug(ci): remove temporary fault-register diagnostic step
nateinaction Jul 23, 2026
5570974
docs: record hardware CI investigation findings for integration-uart/…
nateinaction Jul 23, 2026
a9245d9
fix(boards): correct flash0 size to 16MB, unblocking keystore partiti…
nateinaction Jul 23, 2026
5eee619
docs: record hardware CI result for flash-size fix, narrow root cause
nateinaction Jul 23, 2026
77708d0
debug(ci): capture boot console log to diagnose littlefs mount stall
nateinaction Jul 23, 2026
3cdfa54
debug(ci): revert console diagnostic, record boot-hang finding
nateinaction Jul 23, 2026
f5c3a12
debug(ci): sample core PC/LR at intervals after boot to locate the hang
nateinaction Jul 23, 2026
59b8178
debug(ci): revert PC-sweep diagnostic, record boot-hang location
nateinaction Jul 23, 2026
683c2f8
debug(ci): read-only SWD fault/XIP/stack forensics at the /keys hang
nateinaction Jul 23, 2026
07a78c7
debug(ci): capture reg/stack/mask output in hang forensics (v2)
nateinaction Jul 23, 2026
b336f5f
debug(ci): GDB DWARF backtrace at the /keys hang (v3); record forensics
nateinaction Jul 24, 2026
a302cde
docs: record hardware-confirmed root cause (UsageFault -> fatal-halt …
nateinaction Jul 24, 2026
ec0bdb3
fix: unblock CI boot failure and make the /keys key store real
nateinaction Jul 27, 2026
466ce0c
docs: reset INVESTIGATION.md onto the two remaining open items
nateinaction Jul 27, 2026
cb1d743
fix(test): make the integration suite pass on bench and unblock CI
nateinaction Jul 28, 2026
622d866
fix(ci): give the YAMCS stack the provisioned auth key
nateinaction Jul 28, 2026
30ffaef
docs: record first green CI run on hmac-to-storage (30321256393)
nateinaction Jul 28, 2026
7dcd942
chore: drop investigation scratch files and bench diagnostics
nateinaction Jul 28, 2026
1572f10
Merge remote-tracking branch 'origin/main' into hmac-to-storage
nateinaction Jul 28, 2026
7c13d5d
fix(TcSecurityDeframer): harden the on-flash key store
nateinaction Jul 28, 2026
b214dd8
fix: address PR #472 review on the key store, ground key, and int tests
nateinaction Jul 28, 2026
8bd3725
refactor(TcSecurityDeframer): drop the unused importHmacKey() wrapper
nateinaction Jul 28, 2026
5e81efd
Comment cleanup
nateinaction Jul 29, 2026
42534c0
fix(TcSecurityDeframer): allow PROVISION_KEY on a genuinely blank key…
Mikefly123 Jul 31, 2026
1b70704
Merge branch 'main' into hmac-to-storage
nateinaction Aug 2, 2026
8d2a5ae
feat(Makefile): let make gds pass --spi to fprime-gds
nateinaction Aug 3, 2026
a04d469
docs(README): walk through provisioning the first key
nateinaction Aug 3, 2026
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
47 changes: 28 additions & 19 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,6 @@ jobs:
run: |
make generate

- name: Set Authentication Key
env:
AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
echo "#define AUTH_DEFAULT_KEY \"$AUTH_KEY\"" > PROVESFlightControllerReference/Components/TcSecurityDeframer/AuthDefaultKey.h

- name: Build MCUBoot
run: |
make build-mcuboot
Expand Down Expand Up @@ -154,12 +148,6 @@ jobs:
# here to keep the YAMCS instance config / adapter in sync.
make make-ci-spacecraft-id

- name: Set Authentication Key
env:
AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
echo "#define AUTH_DEFAULT_KEY \"$AUTH_KEY\"" > PROVESFlightControllerReference/Components/TcSecurityDeframer/AuthDefaultKey.h

- name: Install Framer Plugin
run: |
make framer-plugin
Expand All @@ -177,10 +165,18 @@ jobs:
pkill -9 python || true

- name: Start GDS
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
nohup make gds-integration UART_DEVICE="$UART_DEVICE" > gds-bootstrap.log 2>&1 &
echo $! > server.pid

- name: Provision Key
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
make test-integration FILTER=provision_key

- name: Sync Sequence Number
run: |
make test-integration FILTER=sync_sequence_number
Expand All @@ -205,6 +201,8 @@ jobs:
echo "UART_DEVICE=$ZEPHYR_TTY" >> $GITHUB_ENV

- name: Start GDS
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
nohup make gds-integration UART_DEVICE="$UART_DEVICE" > gds.log 2>&1 &
echo $! > server.pid
Expand Down Expand Up @@ -284,6 +282,12 @@ jobs:
- name: Start YAMCS Stack
env:
PYTHONUNBUFFERED: "1"
# proves_adapter.py builds the same authenticated framing the GDS
# plugin does, so it needs the key at construction. Now that the key
# is provisioned onto the satellite instead of compiled into the
# image, this step has to be given it explicitly like every other
# step that starts a framing process.
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
# SPACECRAFT_ID=67 matches the override applied by `make
# make-ci-spacecraft-id` in the build job so the adapter syncs on
Expand Down Expand Up @@ -413,12 +417,6 @@ jobs:
run: |
make submodules fprime-venv

- name: Set Authentication Key
env:
AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
echo "#define AUTH_DEFAULT_KEY \"$AUTH_KEY\"" > PROVESFlightControllerReference/Components/TcSecurityDeframer/AuthDefaultKey.h

- name: Install Framer Plugin
run: |
make framer-plugin
Expand All @@ -432,6 +430,8 @@ jobs:
echo "UART_DEVICE=$ZEPHYR_TTY" >> $GITHUB_ENV

- name: Bootstrap Sequence Number over UART
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
# DIVIDER_PRM_SET and lora.TRANSMIT both pass through the authentication
# router and require the GDS-side sequence number to match FSW. On a
Expand All @@ -446,6 +446,11 @@ jobs:
BOOTSTRAP_GDS_PID=$!
echo $BOOTSTRAP_GDS_PID > server-bootstrap.pid
sleep 5
# Provision the HMAC key on the UART deframer instance; the key store
# is shared board-wide (see TcSecurityDeframer key-store propagation),
# so the LoRa instance picks it up on its next frame without a
# separate provision over radio.
make test-integration FILTER=provision_key
# Sync against the LoRa deframer instance: the radio traffic that
# follows is validated by it, not by the UART instance this
# bootstrap link talks through.
Expand All @@ -469,6 +474,8 @@ jobs:
echo "UART_DEVICE=$ZEPHYR_TTY" >> $GITHUB_ENV

- name: Sync Sequence Number over UART
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
nohup make gds-integration UART_DEVICE="$UART_DEVICE" > gds-sync.log 2>&1 &
BOOTSTRAP_GDS_PID=$!
Expand Down Expand Up @@ -522,6 +529,8 @@ jobs:
pkill -9 python || true

- name: Start GDS on LoRa Passthrough
env:
PROVES_AUTH_KEY: ${{ secrets.AUTH_KEY }}
run: |
# Capture GDS's own stdout/stderr for the whole session (not just what
# pytest captures on failure) so a crash mid-run has a full record of
Expand All @@ -544,7 +553,7 @@ jobs:
env:
TLM_SAMPLE_LOG: tlm_sample_radio.csv
run: |
make test-integration FILTER="not sync_sequence_number and not format_filesystem and not uart_only" PYTEST_ARGS=--with-radio
make test-integration FILTER="not sync_sequence_number and not format_filesystem and not provision_key and not uart_only" PYTEST_ARGS=--with-radio

- name: Format Filesystem
if: always()
Expand Down
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ make generate # Generate F Prime build cache (force)
make generate-if-needed # Generate only if build directory missing
make build # Build firmware (runs generate-if-needed)
make build-mcuboot # Build firmware with MCUBoot bootloader signing
make generate-auth-key # Generate AuthDefaultKey.h with a random HMAC key
make fmt # Run linters and formatters (pre-commit)
make data-budget # Analyze telemetry data budget (use VERBOSE=1 for details)
make docs-sync # Sync component SDD files to docs-site/components/
Expand Down Expand Up @@ -581,13 +580,22 @@ After compiling, upload the sequence through GDS for execution on the board.

### Authentication & Security

Commands can be HMAC-authenticated using the `TcSecurityDeframer` component. The authentication key is stored in `PROVESFlightControllerReference/Components/TcSecurityDeframer/AuthDefaultKey.h`.
Commands can be HMAC-authenticated using the `TcSecurityDeframer` component. The authentication key is **never compiled into the firmware image** (see issue #220); it lives in a dedicated `keystore_partition` on internal flash, mounted as littlefs at `/keys` (`/keys/authkeys.bin`), separate from the SD-card FAT filesystem used for other storage. A keyless board still boots, so it can be provisioned in the field or on the bench.

The key store holds up to 2 slots (`{valid, spi, key}`), keyed by the CCSDS Security Parameter Index (SPI) already present in every frame. Three commands manage it:

- `PROVISION_KEY(spi, key)` — bootstrap. Bypass-allowlisted so it works on a keyless satellite, but the handler only honors it while the store is empty.
- `ADD_KEY(spi, key)` — rotation. Requires an authenticated frame; fails once 2 keys already exist.
- `REMOVE_KEY(spi)` — rotation. Requires an authenticated frame; fails if it would drop the active key count below 1.

The store is the single source of truth shared by all `TcSecurityDeframer` instances (UART/LoRa/Sband): on an unknown SPI, a deframer reloads the store from disk once and retries before rejecting the frame, so a rotation issued over any one link propagates to the others automatically. The ground plugin (`Framing/src/authenticate_plugin.py`) sources its key from the `--authentication-key` CLI arg or the `PROVES_AUTH_KEY` env var — it no longer reads a compiled-in default.

```bash
make generate-auth-key # Generate a new random HMAC key (only if file doesn't exist)
make copy-secrets SECRETS_DIR=<dir> # Copy production keys and auth key from a secure directory
make copy-secrets SECRETS_DIR=<dir> # Copy production keys from a secure directory
```

To provision a keyless board, uplink `PROVISION_KEY(spi=0, key=<hex key>)` over a trusted bench/CI link (see `PROVESFlightControllerReference/test/int/provision_key_test.py`), then give ground the same key via `PROVES_AUTH_KEY`.

Firmware is signed with MCUBoot for secure boot. The signing key is at `keys/proves.pem` (copied from the MCUBoot test key or a production key). Build signed firmware with:

```bash
Expand Down
125 changes: 68 additions & 57 deletions Framing/src/authenticate_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,54 +19,67 @@
SEQUENCE_NUMBER_FILE = os.path.join(_SEQUENCE_NUMBER_DIR, _SEQUENCE_NUMBER_FILENAME)


def get_default_auth_key_from_header() -> str:
"""
Read the authentication key from AuthDefaultKey.h file.
# The flight side (Authenticator.cpp parseHexKey) accepts exactly a 128-bit key as 32 hex
# characters. Anything else either blows up later in bytes.fromhex() during framing or produces
# frames the board silently rejects, so both key sources are normalized and checked here instead.
AUTH_KEY_HEX_LENGTH = 32


def normalize_auth_key(key: str, source: str) -> str:
"""Strip any 0x prefix and validate the key is exactly 128 bits of hex.

Args:
key: The key as supplied by the operator
source: Where it came from, for the error message

Returns:
Default authentication key (without 0x prefix) from AuthDefaultKey.h
The key as 32 hex characters, without 0x prefix

Raises:
FileNotFoundError: If AuthDefaultKey.h file is not found
ValueError: If AuthDefaultKey.h does not contain a valid key
IOError: If there is an error reading the file
ValueError: If the key is not exactly 32 hexadecimal characters
"""
path = (
"PROVESFlightControllerReference/Components/TcSecurityDeframer/AuthDefaultKey.h"
)

if not os.path.exists(path):
raise FileNotFoundError(
f"AuthDefaultKey.h not found at {path}. "
"Authentication plugin requires AuthDefaultKey.h to be present. "
"Ensure the file exists or run 'make generate-auth-key' to create it."
normalized = key.strip()
if normalized[:2].lower() == "0x":
normalized = normalized[2:]

if len(normalized) != AUTH_KEY_HEX_LENGTH:
raise ValueError(
f"Authentication key from {source} is {len(normalized)} hex characters; "
f"expected exactly {AUTH_KEY_HEX_LENGTH} (a 128-bit key)."
)

try:
with open(path, "r") as f:
for line in f:
# Look for line like: #define AUTH_DEFAULT_KEY "4916d208d40612daad6edbc7333c4c13"
if "AUTH_DEFAULT_KEY" in line and '"' in line:
# Extract key from between quotes
start = line.find('"') + 1
end = line.find('"', start)
if start > 0 and end > start:
key = line[start:end]
# Remove 0x prefix if present (shouldn't be, but handle it)
if key.startswith("0x") or key.startswith("0X"):
key = key[2:]
return key
except (IOError, OSError) as e:
raise IOError(
f"Error reading AuthDefaultKey.h from {path}: {e}. "
"Authentication plugin cannot proceed without a valid AuthDefaultKey.h file."
) from e

# If we get here, file exists but contains no valid key
raise ValueError(
f"No valid key found in {path}. "
'AuthDefaultKey.h must contain a line with: #define AUTH_DEFAULT_KEY "<key>"'
)
bytes.fromhex(normalized)
except ValueError as exc:
raise ValueError(
f"Authentication key from {source} is not valid hexadecimal: {exc}"
) from exc

return normalized


def get_auth_key_from_env() -> str:
"""
Read the authentication key from the PROVES_AUTH_KEY environment variable.

The key is never compiled into the flight image (see issue #220), so ground
tooling must be told the key out-of-band: via --authentication-key or this
environment variable.

Comment thread
nateinaction marked this conversation as resolved.
Returns:
Authentication key as a hex string (without 0x prefix) from PROVES_AUTH_KEY

Raises:
ValueError: If PROVES_AUTH_KEY is unset or not a 128-bit hex key
"""
key = os.environ.get("PROVES_AUTH_KEY")
if not key:
raise ValueError(
"No authentication key available: pass --authentication-key or set "
"the PROVES_AUTH_KEY environment variable. The key is provisioned "
"onto the satellite with the PROVISION_KEY command and is never "
"compiled into the flight image."
)
return normalize_auth_key(key, "the PROVES_AUTH_KEY environment variable")


# pragma: no cover
Expand All @@ -87,7 +100,7 @@ def __init__(
spi: Security Parameter Index (default: 0)
window_size: Window size for authentication (default: 50)
authentication_type: Type of authentication (default: "HMAC")
authentication_key: Authentication key as hex string without 0x prefix (default: reads from spi_dict.txt)
authentication_key: Authentication key as hex string without 0x prefix (default: reads from PROVES_AUTH_KEY env var)
**kwargs: Additional keyword arguments (ignored for now)
"""
super().__init__()
Expand All @@ -101,9 +114,15 @@ def __init__(
self.spi = spi
self.window_size = window_size
self.authentication_type = authentication_type
# Use provided key or read from AuthDefaultKey.h
# Use provided key or read from the PROVES_AUTH_KEY environment variable. Either way the
# key is validated up front, so a malformed key fails at startup with a clear message
# rather than mid-run inside frame().
if authentication_key is None:
authentication_key = get_default_auth_key_from_header()
authentication_key = get_auth_key_from_env()
else:
authentication_key = normalize_auth_key(
authentication_key, "--authentication-key"
)
self.authentication_key = authentication_key

def get_sequence_number_from_file(self, filename: str, addition: bool) -> int:
Expand Down Expand Up @@ -153,12 +172,9 @@ def frame(self, data: bytes) -> bytes:

# Security Trailer of 16 octets in length (TM Baseline)
# the output MAC is 2*128 bits in total length. (32 bytes)
# Convert hex string to bytes (16 bytes)
# Keys are stored without 0x prefix, but handle it if present for backward compatibility
key_hex = self.authentication_key
if key_hex.startswith("0x") or key_hex.startswith("0X"):
key_hex = key_hex[2:]
key = bytes.fromhex(key_hex)
# Convert hex string to bytes (16 bytes). The key was normalized and validated in
# __init__, so this cannot fail here.
key = bytes.fromhex(self.authentication_key)

hmac_object = hmac.new(key, data, hashlib.sha256)

Expand All @@ -177,11 +193,6 @@ def deframe(self, data: bytes, no_copy=False) -> tuple[bytes, bytes, bytes]:
@classmethod
def get_arguments(cls) -> dict:
"""Return CLI argument definitions for this plugin"""
# Get default key from AuthDefaultKey.h for help text
try:
default_key = get_default_auth_key_from_header()
except (FileNotFoundError, ValueError, IOError):
default_key = "<not found - run 'make generate-auth-key'>"
return {
("--spi",): {
"type": int,
Expand All @@ -200,8 +211,8 @@ def get_arguments(cls) -> dict:
},
("--authentication-key",): {
"type": str,
"help": f"Authentication key as hex string without 0x prefix (default: {default_key} from AuthDefaultKey.h)",
"default": None, # Will be set to key from AuthDefaultKey.h in __init__
"help": "Authentication key as hex string without 0x prefix (default: reads from PROVES_AUTH_KEY env var)",
"default": None, # Will be set from PROVES_AUTH_KEY in __init__
},
}

Expand Down
Loading
Loading