Skip to content

fix(security): delete the plaintext LFS mount rather than gate it - #919

Merged
forkwright merged 3 commits into
mainfrom
fix/866-plain-mount
Aug 21, 2026
Merged

fix(security): delete the plaintext LFS mount rather than gate it#919
forkwright merged 3 commits into
mainfrom
fix/866-plain-mount

Conversation

@forkwright

Copy link
Copy Markdown
Owner

The finding inverted its own description

MountPlan::Plain mounted LFS at the userdata partition head with no EncryptedBlockDevice, and formatted it on InvalidSuperblock. Its comment called it a "legacy dev/transition path", and #866 asked for it to be removed from production or build-gated as non-production.

It was reachable only in production. mount_plan cannot reach that arm without secure_boot_ok, and kinit sets that at exactly one place — gated on BOOT_KEY_IS_PRODUCTION:

if crate::secure_boot::BOOT_KEY_IS_PRODUCTION {
    state.secure_boot_ok = true;

A dev-anchor build boots degraded with it false, deliberately, so the committed public dev key cannot establish trust on a device. Host tests always build with the dev anchor. QEMU never verifies at all.

So no dev build, no host test and no QEMU witness could ever take that arm, and the only configuration that could was the one where a plaintext persistent root is forbidden. A compatibility path that exists in no configuration but the one it must not run in has nothing left to be compatible with — which is why this deletes it rather than taking the gating option the issue offered. Gating would have produced a branch no build can execute and no test can reach.

What changes on a device

An unprovisioned device whose first-boot setup did not produce a verified passphrase — skipped, interrupted, or unable to reach the operator — now falls back to the initramfs instead of creating a plaintext root. It has never held a secret, so it has no userdata to lose by declining to mount.

The property is structural now, not asserted

MountPlan loses its third variant, so there is no plaintext answer to return. The new exhaustive test walks the whole input space — every combination of emmc_ok, secure_boot_ok, all five PreambleLoad states and passphrase_ok — rather than the states someone remembered to enumerate, and requires every Encrypted result to carry a derived key on a verified boot over a readable preamble.

One thing clippy found that was worth more than silencing

With the arm gone, preamble became an unused parameter — the decision no longer consulted it. _preamble would have compiled.

Instead the check is now stated: Encrypted requires passphrase_ok and a preamble of Provisioned or Unprovisioned. Today that is implied — boot_passphrase_plan answers Skip for the other three, so no key can exist for them — but the implication lives in a different function. The cost of it ever ceasing to hold is mounting the payload of a device whose preamble could not be read, which is exactly what #621 established must never happen. Three new assertions pin that unknown stays locked even if a key somehow exists.

Also retires the plain arm's half of #360

The encrypted mount distinguishes a never-formatted payload from a damaged one via provisioned_this_boot. The plain arm had no such marker and formatted anyway; the comment explaining why that was tolerable is gone with the code it excused.

Verified

Nine-configuration scripts/kernel-clippy.sh clean, cargo fmt --check clean. Full kernel suite on the host target: 2463 tests, 43 failures — exactly the unmodified-main baseline, compared name-by-name against a captured baseline set rather than by module prefix. 129 lines removed from kinit.rs; docs/capability-inventory.toml updated so it no longer describes a plaintext path that does not exist.

Closes #866

The Unprovisioned mount arm mounted LFS at the userdata partition head with
no EncryptedBlockDevice, and formatted it on InvalidSuperblock. #866 asked
for it to be removed from production or build-gated as non-production.
Removing it outright, because gating it would have produced a branch no
build can execute.

It was reachable ONLY in production. mount_plan cannot reach the arm without
secure_boot_ok, and kinit sets that at exactly one place, only when
BOOT_KEY_IS_PRODUCTION -- a dev-anchor build boots degraded with it false,
by design, so the deliberately-public committed dev key cannot establish
trust on a device. Host tests always build with the dev anchor and QEMU never
verifies at all. So the "legacy dev/transition path" its own comment
described was wrong about its own reachability: no dev build, no host test
and no QEMU witness could take it, and the only configuration that could was
the one where a plaintext persistent root is forbidden.

That inverts the usual disposition. A compatibility path that exists in no
configuration but the one it must not run in has nothing left to be
compatible with.

What changes on a device: an unprovisioned device whose first-boot setup did
not produce a verified passphrase -- skipped, interrupted, or unable to reach
the operator -- now falls back to the initramfs instead of creating a
plaintext root. It has never held a secret, so it has no userdata to lose by
declining to mount.

MountPlan loses its third variant, so the property is structural rather than
asserted: there is no plaintext answer to return. The new exhaustive test
walks the whole input space rather than the states someone remembered to
enumerate, and requires every Encrypted result to carry a derived key on a
verified boot.

This also retires the plain arm's half of #360. The encrypted mount separates
a never-formatted payload from a damaged one with provisioned_this_boot; the
plain arm had no such marker and formatted anyway, and the comment explaining
why that was tolerable is gone with the code it excused.

Closes #866
@forkwright
forkwright force-pushed the fix/866-plain-mount branch from 315d8d6 to 705608c Compare August 21, 2026 10:49
@forkwright
forkwright merged commit cff4e35 into main Aug 21, 2026
12 checks passed
@forkwright
forkwright deleted the fix/866-plain-mount branch August 21, 2026 11:24
forkwright pushed a commit that referenced this pull request Aug 21, 2026
🤖 I have created a release *beep* *boop*
---


## [0.9.0](v0.8.7...v0.9.0)
(2026-08-21)


### Features

* **security:** derive secret verifiers with Argon2id from page-backed
memory ([#915](#915))
([4667bb8](4667bb8)),
closes [#272](#272)
* **security:** enforce the ~77-bit boot-secret floor at first-boot
setup ([#912](#912))
([f071f42](f071f42))
* **watchdog:** pet only while every liveness owner is still advancing
([#920](#920))
([33791a8](33791a8)),
closes [#875](#875)


### Bug Fixes

* **ci:** count the ledger's runnable tests after combining profiles,
not before ([#916](#916))
([653e0df](653e0df)),
closes [#855](#855)
* **clock:** separate authentication from precedence and refuse
implausible time
([#906](#906))
([b7f9205](b7f9205))
* **crypto:** enable zeroize on every keyed cipher crate and prove it at
compile time ([#900](#900))
([e2218fc](e2218fc))
* **csprng:** credit cadence departure, not raw sample-to-sample bit
flips ([#901](#901))
([7b01d75](7b01d75))
* **kernel:** map the whole reserved window, and derive its bound from
one place ([#918](#918))
([25ccf87](25ccf87)),
closes [#917](#917)
* **klesis-core:** reject an out-of-domain CSQ reading and cover the six
untested guards
([#909](#909))
([1f174d6](1f174d6))
* **krypta:** hold ratchet key material in a type whose drop scrubs it
([#904](#904))
([0c7f6df](0c7f6df))
* **krypta:** make the ratchet commit atomic and stop it being
duplicable ([#908](#908))
([dd3ba99](dd3ba99))
* **matrix_crypto:** drop the Olm ratchet fields nothing can advance,
scrub what the handshake used
([#903](#903))
([d733857](d733857))
* **matrix_crypto:** name the Olm pre-key derivation for what it is
([#913](#913))
([8c217c2](8c217c2))
* **pteron:** close the four remaining SMP findings
([#910](#910))
([e96b7c7](e96b7c7))
* **security_mode:** store a versioned Sentinel-exit PIN verifier with a
per-device salt
([#896](#896))
([bbf9c86](bbf9c86))
* **security:** delete the plaintext LFS mount rather than gate it
([#919](#919))
([cff4e35](cff4e35)),
closes [#866](#866)
* **security:** gate radio-affecting threat action on calibration and
authorization ([#921](#921))
([c9562fd](c9562fd))
* **security:** require a real provisioning anchor, or carry none at all
([#922](#922))
([a5016e8](a5016e8))
* **security:** verify every stored secret through one salted, iterated
record ([#898](#898))
([b5cb2c2](b5cb2c2))
* **sms:** apply the SCTS timezone octet so decoded timestamps are UTC
([#905](#905))
([ad048fe](ad048fe))
* **stegnos:** reject a persisted PBKDF2 iteration count above the
maximum ([#899](#899))
([87fdfad](87fdfad))
* **thumos:** close all eight findings in the low-severity kernel batch
([#911](#911))
([8160589](8160589))
* **thumos:** close the three live findings in the wave-1 batch
([#907](#907))
([891da7e](891da7e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: retire the plaintext LFS compatibility mount from production boot

1 participant