fix(TcSecurityDeframer): a factory-fresh board cannot be provisioned (ship blocker) - #485
Merged
Merged
Conversation
… store A board whose key store file does not exist - a brand-new board, or one whose /keys partition was erased or reformatted - could not be provisioned. It came up keyless (correct) and then refused PROVISION_KEY with StoreUnreadable forever, which is total, unrecoverable command loss over every link. Root cause is a host/target divergence. The gate added in b214dd8 reads if (loadStatus != OP_OK && loadStatus != DOESNT_EXIST) -> StoreUnreadable but ZephyrFile::open (lib/fprime-zephyr Os/File.cpp) discards fs_open's errno and returns OTHER_ERROR for every failure, so Os::File::DOESNT_EXIST is unreachable on flight hardware. On the POSIX host the same missing file maps ENOENT -> DOESNT_EXIST and the gate passes, which is why host unit tests never saw it. CI hardware never saw it either: every bench board was seeded under 69dcec7 (which gated only on activeKeyCount(), reading the default-empty in-memory store), and the littlefs keystore partition survives reflashing, so the cold path is never exercised. The naive fix - map -ENOENT to DOESNT_EXIST in the Zephyr shim - would reintroduce the hole b214dd8 closed. Zephyr's fs_get_mnt_point returns -ENOENT when the mount point itself is absent, identically to a missing file, so ENOENT alone would let an attacker who can keep /keys from mounting provision their own key over a board that still holds a valid one (PROVISION_KEY is bypass-allowlisted, i.e. unauthenticated and reachable over RF). Instead, stop inferring anything from the read status and probe the filesystem: * fs_stat on the store file (via FileSystem::_getPathType, not getPathType(), which folds every error into NOT_EXIST) for a positive absence answer; * fs_statvfs on the mount point (via FileSystem::getFreeSpace) as independent proof that /keys is actually mounted. Provisioning is permitted only for a demonstrably-absent file on a demonstrably live mount, or a store that read back cleanly and holds no key. An unreadable store is never provisionable, so the security property is preserved. ADD_KEY and REMOVE_KEY use the same predicate, since their gates had the same defect. The decision itself lives in Components::KeyStore in Types.hpp as pure C++ over probe results rather than over an Os status, so the divergence has nowhere to hide and the host gtest suite can cover the whole domain. A plain component test could not have caught this bug, since on the host the buggy code passes. - Types.hpp: KeyStore::MountProbe / StoreProbe / storeStateIsKnown / storeIsProvisionable (pure, header-only, no Fw:: or Os:: dependency) - TcSecurityDeframer: probeKeyStore() + the three rewritten gates - test_TcSecurityDeframer_KeyStorePolicy.cpp: exhaustive mount x store x key-count matrix, plus a test that documents the host/target divergence - provision_key_test.py: spell out why NotEmpty (and only NotEmpty) is tolerated, and add an explicit skipped test marking the cold-provision coverage gap - sdd.md: document the probe and why the read status is not a usable signal Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
nateinaction
approved these changes
Jul 31, 2026
Collaborator
|
Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On
hmac-to-storageas it stands, a board whose key store file does not exist can never be provisioned. It boots keyless (correct) and then refusesPROVISION_KEYwithStoreUnreadable, permanently. That is total, unrecoverable command loss on any brand-new board, or any board whose/keyspartition is erased or reformatted — including the recovery path you would reach for after a keystore corruption.This PR targets
hmac-to-storage, notmain.Failure mode
PROVISION_KEY(andADD_KEY/REMOVE_KEY) gate on the key store re-read:The chain:
loadKeyStore()→Utilities::FileHelper::readFromFile()→Os::File::open(..., OPEN_READ).lib/fprime-zephyr/fprime-zephyr/Os/File.cpp:77-80,ZephyrFile::open:Os::File::DOESNT_EXISTis unreachable on the Zephyr target. A missing file yieldsOTHER_ERROR.OTHER_ERRORis neitherOP_OKnorDOESNT_EXIST→StoreUnreadable→ refused. Forever, since the only way to get a key on is the command being refused.That
File.cppcode is byte-identical at the pre-PR pin5772b491and this PR's pin60d395ed, so the pin bump does not change it.Regression origin
Commit
b214dd88c26("fix: address PR #472 review…") added theStoreUnreadablegate. The original69dcec747d6gated only onactiveKeyCount() != 0, which reads the in-memorym_keyStore— default-empty on a fresh boot — and therefore worked on a cold board.Why CI and unit tests stayed green
69dcec747d6, and the littlefs keystore partition survives reflashing. The store file is never absent, so the cold path is never exercised. Those boards returnNotEmpty, which the integration test (correctly, for that state) tolerates.Oslayer, whereopen()does mapENOENT→DOESNT_EXIST. The buggy gate passes on the host. This is a pure host/target divergence: no component-level host test could have caught it.Why the obvious fix is a security hole
The tempting fix is to map
-ENOENT→DOESNT_EXISTin the ZephyrOs::Fileshim. That reintroduces exactly whatb214dd88c26closed.PROVISION_KEYis bypass-allowlisted (Components/ProvesRouter/Bypasser.cpp) — unauthenticated and reachable over RF. So if a store we merely failed to read counted as "empty", an attacker who can induce a read failure installs their own key while a valid key still sits on flash: full hijack.And
-ENOENTcannot carry that weight. In Zephyrsubsys/fs/fs.c,fs_open→fs_get_mnt_pointreturns-ENOENTwhen the mount point itself is not mounted (fs.c:118-119, 147-150) — the same code as a genuinely missing file. So "ENOENT ⇒ empty ⇒ provisionable" hands the satellite to anyone who can keep/keysfrom mounting.The fix
Stop inferring anything from the read status; probe the filesystem for the two facts that actually matter:
/keysreally mounted?Os::FileSystem::getFreeSpace("/keys")→fs_statvfsOs::FileSystem::getSingleton()._getPathType(path)→fs_stat, keeping the real statusDOESNT_EXISTis a positive "not there"(
Os::FileSystem::getPathType()— the convenience static — is deliberately not used: it folds every error intoNOT_EXIST, which would turn an I/O error into a false "absent" and re-open the hole.)Admission policy, in
Types.hppas pure C++:Note the asymmetry:
Presentneeds no mount probe (a successful full read is proof the FS served the file);Absentrequires one, because absence alone is ambiguous on Zephyr.The security property is preserved and is now testable: an unreadable store is never provisionable, and an "absent" file on an unproven mount is never provisionable.
ADD_KEY/REMOVE_KEYget the same predicate — their gates had the identical defect, so on target they too would refuse on a store that Zephyr reports asOTHER_ERROR.Scope note: this is solved entirely inside
proves-core-reference. FixingZephyrFile::open's errno handling upstream is still worth doing, but it is a separate fork PR + submodule pin bump, and on its own it would not be sufficient (see the mount-point ambiguity above).Regression protection
A plain host gtest cannot catch this bug class, so the decision was extracted into a pure, injectable seam that consumes probe results rather than an
Osstatus. There is no "missing file" status input left to get wrong.test/unit-tests/test_TcSecurityDeframer_KeyStorePolicy.cpp(7 tests):DOESNT_EXISTon host andOTHER_ERRORon target, and the legacy status-based gate gave opposite answers for the same board state. Anyone reintroducing a raw-status gate trips this test's rationale.test/int/provision_key_test.py:NotEmptytolerance is now documented as the only tolerated failure, with an explicit note thatStoreUnreadablemust never be accepted here — on a keyless board it is precisely the cold-lockout signature, and accepting it is how this shipped. (The assertion was already narrow; the reasoning was not written down.)test_cold_provision_gap, a skipped test that names the coverage hole rather than faking it. A real cold test needs a genuinely blank/keys, and nothing in CI can produce one: every board is already provisioned, littlefs survives reflashing, and the existingfsFormat.FORMATformats the FatFS/, not the littlefs/keys. Closing it needs new firmware capability (an authenticated keystore-erase command, or flashing a blank keystore image over SWD). I did not invent a mock cold board, since a mock would re-hide the very divergence at issue.What I verified vs. did not
Verified:
make test-unit: 9/9 test binaries pass, including the newtest_TcSecurityDeframer_KeyStorePolicy(7/7). Full run:pre-commitgate passes on the commit (clang-format, cpplint, ruff, codespell, interrogate, SDD docs sync).ZephyrFile::openclaim and thefs_get_mnt_point-ENOENTbehaviour were read directly from the pinned sources, and confirmed byte-identical between5772b491and60d395ed.NOT verified — please check these on the bench before merging:
lib/zephyr-workspace/zephyrsubmodule, soTcSecurityDeframer.cpphas not been compiled for the target (or at all — the host gtest suite only compiles the pure layer). The new code usesOs::FileSystem::getSingleton()._getPathType()andOs::FileSystem::getFreeSpace(); both are public and present at this pin, but a compile is the real check.lib/fprime/lib/fprime-extraswere temporarily symlinked from a full checkout to run the host gtest suite; the symlinks are not part of the commit./keysnow acceptsPROVISION_KEY— is untested on hardware. The decisive test is: erase/reflash the keystore partition on a bench board, boot, and confirmPROVISION_KEYyieldsKeyProvisionedrather thanKeyProvisionFailed(StoreUnreadable).fs_statvfson a littlefs mount is assumed to succeed on the flight config. If littlefs is built without statvfs support and returns-ENOTSUP,probeMount()reportsUnknownand provisioning stays refused — same symptom, different cause. Worth confirming on hardware in the same session as the point above.Out of scope
Reported separately, deliberately not touched here: the missing
no-format;on the littlefs fstab node, the absence of a keystore recovery command, the rotation reload bug, and seq-file locking.🤖 Generated with Claude Code