Skip to content

fix(init): read the state-key mode from the volume after the first boot - #305

Merged
Bugs5382 merged 3 commits into
mainfrom
fix/297-state-key-mode-before-unlock
Sep 30, 2026
Merged

Bugs5382 merged 3 commits into
mainfrom
fix/297-state-key-mode-before-unlock

Conversation

@Bugs5382

@Bugs5382 Bugs5382 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What and why

A node whose state_key.mode comes from its machine config, and differs from the image's build-time default, can't come back from any reboot after its first boot. For example, nodeid on a STATEKEY=tpm image.

Root cause. boot resolved the mode before unlock with preUnlockStateKey, which reads only the ESP config stage. loadOrSeedConfig deletes that stage on the first boot. On every later boot the function returned the zero StateKey, init fell back to StateKeyMode (the build-time default), and OpenStateVolume used the wrong protector. For example, it looked for a TPM token on a nodeid volume, which has none, or derived a UUID key for a TPM-sealed volume. With panic=1 the node reboot-loops.

Fix: read the mode from the LUKS2 header (option a). The volume records how it was sealed, so later boots derive the mode from its token types:

Header tokens Mode
any cryptos-tpm2 token (one per bootable image after an upgrade or rollback) tpm
a cryptos-kms token kms, with the endpoint and trust bundle the token carries
no protector token nodeid (it persists none)
both TPM and KMS tokens refused: the boot fails with an error, since there's no way to tell how the volume was sealed

The first boot still takes the mode from the staged config, else the build-time default. I chose this over persisting the mode on the ESP (option b) because the header can't drift from how the volume was actually sealed. It adds no new file for an upgrade or a rollback to keep in step, and the multi-token TPM header from #264/#269 keeps reading as tpm.

The pre-unlock sequence moved into a small seam, stateUnlocker in internal/init/stateunlock.go, so it can be tested end to end: first boot, later boots, and opening the volume. boot calls it in place of the inline code. The state device is now resolved before the backends are built (the TPM is opened after IsLUKS, not before).

Found while testing: KMS mode could not finish a first boot. Its LUKS2 token had no type or keyslots, which LUKS2 requires, so real cryptsetup token import refused it (Failed to import token from file.). The token now has type: cryptos-kms and the state keyslot. That type is also how a later boot recognizes a KMS volume. No KMS node can exist with the old token, because none could get through its first boot.

Changing the mode on an installed node is refused. The mode is fixed at install.

  • ApplyConfig on a running node (node.ConfigStore.WithSealedStateKeyMode) refuses a config that names a different state_key.mode with FailedPrecondition, and nothing is written.
  • The re-provision installer does the same.
  • An empty mode, or the same mode, passes.
  • If the persisted config disagrees with the header anyway (for example, a ceremony config), the boot logs it and keeps the sealed mode.

So the node can't end up unbootable. To change the mode, reinstall.

Closes #297

Related: #296 (where it was found; its image suite skips the Root upgrade step for this bug, and that step can be re-enabled once #296 rebases onto this), #264, #269.

Docs: build/README.md (the STATEKEY section says the config can override it, the mode is fixed at install, and a change is refused) and docs/image-upgrade.md (a new "State-key mode" section: an upgrade never changes the mode). Companion docs PR: CryptOS-PKI/docs#52 updates the state_key reference and the sealed-keys concept page, which today tells readers to match the mode to the image because of this bug. The README needs no change.

Verification

  • Lint clean (golangci-lint: 0 issues; go vet; gofmt; task license)
  • Tests pass (go test ./...)
  • Build succeeds
  • Documentation updated

How this was verified

Tests first. internal/init/stateunlock_test.go was written against the seam while it still had the old behaviour. It failed for the reported reason:

  • tpm image, nodeid configured, boot 2: no cryptos-tpm2 token recovers the key ... Token is not in use.
  • nodeid image, tpm configured, boot 2: luks: Open: ... No key available with this passphrase.
  • both kms cases, boot 1: ImportToken: ... Failed to import token from file.
  • the upgraded-TPM-volume and later-stage cases failed the same way.

All pass after the fix.

New tests:

  • TestStateUnlock_ConfiguredModeSurvivesReboots, against an in-memory LUKS2 header. Cases: tpm image with nodeid configured, nodeid image with tpm, tpm or nodeid image with kms, and no mode configured on either image. Each case runs a first boot and two later boots, and each boot checks the mode, the protector, and that the volume opens with the right key.
  • TestStateUnlock_ConfiguredModeSurvivesReboots_RealCryptsetup: the same cases against a real LUKS2 header in a file. The key check uses luksOpen --test-passphrase, so it needs no root.
  • TestStateUnlock_UpgradedTPMVolume_RealCryptsetup: after a reseal adds a second TPM token and the install copy stops unsealing, the volume still reads as tpm and opens with the other copy.
  • TestStateUnlock_LaterStageCannotChangeTheMode: a stage that shows up on a later boot doesn't override the sealed mode.
  • TestSealedStateKey: the derivation table, including foreign token types and the TPM+KMS refusal.
  • TestStateKeyCheckSealed, TestConfigStoreApply_RefusesStateKeyModeChange, TestReprovisioner_RefusesStateKeyModeChange: a mode change is refused with FailedPrecondition, and nothing is persisted or rebooted.

Full gate: task ci (fmt, lint, vet, test, build) passes locally, with cryptsetup 2.7.0 present so the real-cryptsetup tests ran.

QEMU + swtpm:

Debug (qemu-dev) UKIs built locally from main (0c657fb) and from this branch (3c1d744). Each ran in QEMU (TCG, OVMF, swtpm, SMBIOS UUID set) from a pre-installed disk with state_key.mode staged on the ESP. The run is a first boot plus two reboots on the same disk and TPM state. "Up" means the mTLS listener answered.

Image Configured Boot 1 Boot 2 Boot 3
main, STATEKEY=tpm nodeid up, state key mode: nodeid fails, loops: state key mode: tpm, OpenStateVolume: no cryptos-tpm2 token recovers the key -
main, STATEKEY=nodeid tpm volume formatted, state key mode: tpm fails, loops: state key mode: nodeid, OpenStateVolume: recover key: nodeid: ... -
this branch, STATEKEY=tpm nodeid up in 35 s, nodeid up in 26 s, the volume is sealed in nodeid mode up in 25 s, same
this branch, STATEKEY=nodeid tpm up in 45 s, tpm up in 25 s, the volume is sealed in tpm mode up in 25 s, same

The main runs reproduce the reported bug in both directions. The branch runs come back on every reboot with the mode the volume was sealed with. KMS wasn't booted, because there's no KMS endpoint in this setup. It's covered by the unit and real-cryptsetup tests. The #296 image suite's skipped Root upgrade step wasn't re-enabled here, because #296 isn't on main and this PR leaves it alone. It can be turned back on when #296 rebases onto this.


Before merging: add a closing comment summarizing what was actually done in this PR
(not just the checked boxes).

@Bugs5382 Bugs5382 self-assigned this Sep 30, 2026
@Bugs5382
Bugs5382 marked this pull request as ready for review September 30, 2026 16:08
@github-actions github-actions Bot added the fix Bug fix (fix). Patch. label Sep 30, 2026
A node whose state_key.mode came from its machine config and differed
from the image's build-time default could not come back from any reboot.
init resolved the mode before unlock from the ESP config stage only, and
the stage exists only on the first boot, so every later boot fell back to
the build-time mode and opened the volume with the wrong protector.

Later boots now derive the mode from the LUKS2 header the volume was
sealed with: a cryptos-tpm2 token means tpm, a cryptos-kms token means
kms (with the endpoint and trust it carries), and no protector token
means nodeid. The first boot still takes it from the staged config, else
the build-time default. The multi-token TPM header left by an upgrade
still reads as tpm.

The KMS token carried no type or keyslots, which LUKS2 requires, so
cryptsetup refused to import it and kms mode could not finish a first
boot. It now has type cryptos-kms and the state keyslot.

The mode is fixed at install. ApplyConfig on a running node and the
re-provision installer refuse a config naming another state_key.mode with
FailedPrecondition, and a boot whose persisted config disagrees logs it
and keeps the sealed mode.

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the fix/297-state-key-mode-before-unlock branch from a026075 to beac95e Compare September 30, 2026 16:30
@Bugs5382

Copy link
Copy Markdown
Contributor Author

Merging for the v0.1.0 test run: CI green and mergeable at merge time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (fix). Patch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(init): read the configured state-key mode before unlock so a node survives reboots

1 participant