fix(keystore): fail closed when the OTP trust anchor cannot be read - #59
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
srpatcha
left a comment
There was a problem hiding this comment.
Verified — and the framing is the right one
"Which public key does this device trust?" is the question every other secure-boot decision rests on, and answering it with something weaker when hardware misbehaves is worse than refusing to answer. The parallel you draw to #38 — CRC32 failing open on an unreadable flash region — is exact: same failure mode, one layer up, and the layer that matters more.
The revocation case is the one I would not want left:
uint8_t revoke_flags = 0;
if (eos_hal_otp_read(OTP_REVOKE_OFFSET, &revoke_flags, 1) == EOS_OK) {
...
}An OTP read that fails leaves revoke_flags at its initialiser, so an unreadable revocation store revokes nothing. That is precisely backwards: a device that cannot read which keys were revoked is a device that must assume the worst, not the best. And it is silent — no branch, no log, just a key that was revoked continuing to verify images.
Fail-closed is the only defensible default here. A bootloader that refuses to boot is recoverable; one that boots an image signed by a revoked key is not.
Verification
Merged onto origin/master + #58: 0 build errors, ctest 16/16.
Not verified against master alone — master does not build (eos_crc32 conflicting types), which #58 repairs.
One blocker outside this PR
eBoot has required_signatures: true on master, and your commits are unsigned, as are every other contributor's on this repo. That makes this PR — and #57, #58, #61, #62 — unmergeable regardless of review. eos, ebuild, eDB and most of the org do not have that setting; eBoot, eCAD and eNI do. I have flagged it to the maintainer as a policy decision: turn it off, or ask contributors to sign. Nothing for you to fix.
Every decision secure boot makes rests on which public key the device trusts.
keystore.c resolves that key, and three of its paths resolve it to something
weaker when hardware misbehaves rather than refusing.
**An unreadable revocation store revokes nothing.** eos_keystore_init() applies
the OTP revocation flags only when the read succeeds:
uint8_t revoke_flags = 0;
if (eos_hal_otp_read(OTP_REVOKE_OFFSET, &revoke_flags, 1) == EOS_OK) {
if (revoke_flags & 0x01) ks->slots[0].revoked = true;
On failure the flags stay clear and every key looks live. Revocation exists to
retire a key whose private half is believed compromised, so "I could not check"
must mean "do not use". It now marks every OTP slot revoked, and
eos_keystore_get_active_key() reports EOS_ERR_KEY.
**A failed OTP read is treated as no OTP at all.** Any non-EOS_OK result skipped
the whole OTP block and fell through to the compiled-in key. EOS_ERR_NOT_SUPPORTED
(this board has no OTP) and EOS_ERR_FLASH (this board has one and reading it
failed) are not interchangeable: the second means the provisioned trust anchor
is unknown, and quietly substituting the compiled-in key lets a fault on the OTP
bus choose which key the device trusts. Only the former now falls back.
**Revoking a slot cleared the others, and reported success when it did not
stick.** eos_keystore_revoke_slot() ignored the return of its read-modify-write:
uint8_t revoke_flags = 0;
eos_hal_otp_read(OTP_REVOKE_OFFSET, &revoke_flags, 1);
revoke_flags |= (1U << slot);
eos_hal_otp_write(OTP_REVOKE_OFFSET, &revoke_flags, 1);
If the read failed, revoke_flags stayed 0 and writing it back cleared every
other slot's revocation bit -- revoking slot 1 un-revoked slot 0. If the write
failed, the revocation lived in RAM only and was gone at the next reset, while
this returned EOS_OK and the caller believed the key was permanently retired.
Both are now reported. The in-RAM revocation stands either way, so the current
boot still refuses the key.
Also: the compiled-in default key is the public half of TEST 1 in RFC 8032
section 7.1, whose private key is printed in the RFC -- anyone can sign an image
a bootloader trusting it will accept. It is a reasonable default for bring-up
and for these tests, and shipping it silently is the failure mode, so a build
without EBLDR_PRODUCTION_KEY now says so on every compile. No -Werror here, so
this does not break a build.
Verified: ctest 16/16 pass, pytest 13 passed 1 skipped. Five new keystore tests
cover each path above; against the unpatched keystore.c the first of them fails
on exactly the assertion it exists to make.
Stacked on embeddedos-org#58, which restores the build -- master does not compile, so these
tests cannot run without it. Rebase target once embeddedos-org#58 or an equivalent lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
01a199c to
3276220
Compare
|
Rebased onto current The PR is now the keystore change and nothing else: 2 files, +221/−12. Re-verified on the rebased branch:
Nothing in the fix itself changed — Noted on the |
The architectural problem
Every decision secure boot makes rests on one question: which public key does this device trust?
core/keystore.canswers it. Three of its paths answer it with something weaker when hardware misbehaves, rather than refusing to answer.This is the same shape as #38 (CRC32 failing open on an unreadable flash region), one layer up: the failure isn't in checking the image, it's in establishing what to check it against.
An unreadable revocation store revokes nothing
On failure the flags stay clear and every key looks live. Revocation exists to retire a key whose private half is believed compromised, so "I could not check" has to mean "do not use" — not "not revoked". It now marks every OTP slot revoked, and
eos_keystore_get_active_key()reportsEOS_ERR_KEYinstead of handing back a key that may have been retired.A failed OTP read is treated as no OTP at all
Any non-
EOS_OKresult skipped the whole OTP block and fell through to the compiled-in key. ButEOS_ERR_NOT_SUPPORTED(this board has no OTP) andEOS_ERR_FLASH(this board has one and reading it failed) are not interchangeable. The second means the trust anchor this device was provisioned with is unknown, and quietly substituting the compiled-in key lets a fault on the OTP bus choose which key the device trusts. Only the former falls back now.Revoking a slot cleared the others, and reported success when it didn't stick
revoke_flagsstayed0, and writing it back cleared every other slot's revocation bit — revoking slot 1 un-revoked slot 0.EOS_OKand the caller believed the key was permanently retired.Both are reported now. The in-RAM revocation stands either way, so the current boot still refuses the key.
Also: the default trust anchor is a published keypair
That is the public half of TEST 1 in RFC 8032 §7.1. The matching private key is printed in the RFC, so anyone at all can produce a signature a bootloader trusting this key will accept. It is a reasonable default for bring-up and for these tests — and shipping it silently is the failure mode. A build without
EBLDR_PRODUCTION_KEYnow says so on every compile via#warning. There is no-Werrorhere, so this does not break a build.I have deliberately not made it a hard error: that is a policy call for maintainers, and it would break the default board build.
Verification
ctest --no-tests=errorpytest tests/Five new tests in
tests/unit/test_keystore.c, driven through a simulated OTP registered as a board:0x03, not0x02)Against the unpatched
keystore.cthe first of these fails on exactly the assertion it exists to make. The four pre-existing keystore tests are unchanged and still run against the compiled-key path, since no board is registered for them.Stacking
Based on #58, which restores the build —
masterdoes not compile, so these tests cannot run without it. Rebase target once #58 or an equivalent (#55, #57) lands; the keystore changes themselves are independent of all of them.🤖 Generated with Claude Code