TacidOS
Developers
Documentation

Decision 0080

0080. A firmware told to verify a chain nobody signed is a brick

Architecture decision record 0080: 0080. A firmware told to verify a chain nobody signed is a brick

Status
accepted
Date
2026-09-07

Status: accepted. Builds the Secure Boot half of ADR-0012, and applies ADR-0077’s rule to a third operation.

Context

ADR-0012 settled Secure Boot in one sentence — Lanzaboote, integrated from the first ISO, enrolled after installation with tacid secureboot, opt-in until the hardware matrix passes — and nothing was built. What existed was the shape of the last step: Operation::SecureBootEnrol, guarded, running sbctl enroll-keys, and a doctor finding whose remedy named a command that did not exist.

The step is not the feature. Enrolling keys into firmware is the only act on a Tacid machine that a later generation cannot take back. Everything else here — a bad generation, a wrong package, a declaration somebody regrets — is one boot away from being undone, because the previous generation is still on the disk and the bootloader still offers it. After enrolment the firmware runs what those keys signed and nothing else, including the generation before, including the installer, and including the recovery you would have used.

So the question this record answers is not “how does a machine sign its boot files”. It is: what has to be true before a machine is allowed to ask its firmware to start refusing things.

Three things were also wrong in what already existed, and reading them is what produced the design below.

The operation carried the key directory as an argument. SecureBootEnrol(SecureBootKey { key_path }), and the stem checked the string looked like a path. It looked like a check. What it permitted was a caller naming a directory it had just written — handing the firmware a key nobody chose, for the rest of the machine’s life. Checking the shape of an argument answers a different question from the one that matters.

Nothing could see setup mode. The doctor’s remedy said “with the firmware in setup mode” to every machine, including the ones already in it. The state is one EFI variable away, at the same offset as the one already read.

Boot counting lives in the installer. A machine that signs its boot files does not use systemd-boot’s own installer; Lanzaboote writes the entries. boot.loader.systemd-boot.bootCounting then does nothing, and the machine would gain a verified boot chain and silently lose the reflex that gets it back from a generation that cannot boot — trading one guarantee for another without telling anybody.

Decision

Four steps, and the machine names the one you are on. Keys, then a declaration and a rebuild, then a reboot, then enrolment.

tacid secureboot keys                 a signing key pair of this machine's own
tacid.core.boot.secureBoot.enable     declared in the genome, and rebuilt
                                      one reboot, to prove the signed chain boots
tacid secureboot enrol                the firmware is told to trust it

The middle step is a declaration and not a verb, because turning on a boot chain the machine signs is a change to the machine, and changes to the machine are declared and go through the transaction path like every other one. The reboot is not enforced and is not decoration: it is the last moment at which a mistake is free.

Four gates on the last step, none of which trusts the others. The keeper refuses a principal who could not approve a change; refuses a machine with no keys; and refuses a machine that does not sign with them. The stem refuses again without a person at the machine. sbctl refuses again unless the firmware is in setup mode. The third of these is the one that prevents the brick, and it is in the keeper rather than in the command line because a check at one door is not a check (ADR-0076).

Whether this machine signs is told to the keeper, not inferred by it. TACID_SECURE_BOOT on the keeper’s unit, from the one option. The keeper cannot work it out: it is a property of how the system was built, and the alternatives are guessing from a directory listing — answering a question about signatures by counting files — or reading the EFI partition, which it cannot open and should not. A wrong yes here is the brick. So the module says, and a test holds the module to saying it from that option and nothing else.

/etc/sbctl/sbctl.conf is written on every machine, not only the ones that sign. Making keys is the first of the four steps and turning the option on is the second, so a config file that arrived with the option would arrive one step too late — and sbctl would have made the keys in /etc/secureboot, which is what nixpkgs compiles into it, while Lanzaboote later signed from a /var/lib/sbctl that had none. Evaluation found this: Lanzaboote writes the same file when it is told to generate keys itself, and two writers of one file is a build error, which is the good kind of finding.

The keys are persistent state, declared as such. A root that threw /var/lib/sbctl away would come back after a reboot unable to sign its next generation — and on a machine whose firmware had been told to trust those keys, that generation is one the firmware refuses. They are the one piece of state on a Tacid machine a reinstall cannot replace: the firmware remembers them, and only the owner can tell it to forget.

Neither operation carries an argument. tacid_stem_protocol::SECURE_BOOT is the directory, beside UPDATE_UNIT and for its reason. Four things need one answer — the stem runs sbctl, the module writes /etc/sbctl/sbctl.conf so the tool finds it, Lanzaboote signs from it, and the keeper stats keys under it — and a machine that signed with one directory and enrolled from another would enrol keys it had never signed with. The shape check that guarded the old argument is deleted along with the argument, and is_plausible_fs_path with it.

Making keys is not guarded; enrolling is. A key pair in a directory boots nothing and verifies nothing. Asking for a person to be present for it would be asking them for nothing, and a person asked for nothing often enough stops reading the question. Both are decisions about the machine, so both need a principal who could approve a change — may_approve alone admits any numbered user, which on a machine with a guest account would let a guest decide what signs its boot.

The bundle directory is traversable and its contents are not. /var/lib/sbctl is 0755, made by tmpfiles; keys/ under it is sbctl’s and stays root’s. That is exactly enough for the keeper — which runs as nobody in particular — to answer “does this machine have keys” with a stat, and not enough for it to read a private key. A bundle it could not enter would make the question permanently unanswerable; one it could read would be one more process holding the key.

--microsoft, kept. Microsoft’s certificates go into the database beside this machine’s own. Without them a machine with a discrete card may show a black screen and no message: the option ROM the firmware runs before any operating system is signed by Microsoft and by nobody else. Sovereignty that costs the owner their display is a principle nobody keeps. It is a test rather than a comment, because the failure looks like broken hardware rather than like a policy somebody chose.

Boot counting is asked for on both sides, from one option. tacid.core.boot.secureBoot.tries sets boot.lanzaboote.bootCounting.initialTries, so the immune system survives the change of installer.

Lanzaboote is a flake input, and its module is imported by every machine. An option every machine has, which on being set to true answers “the option boot.lanzaboote.enable does not exist”, is a promise the module surface does not keep. Importing costs a machine that leaves it off nothing: the tool is behind lib.mkIf and behind a package option’s default, and Nix builds neither until something asks. Its inputs, and the two inputs it needs, are carried in tacid.core.inputs and pinned into a genome’s lock, because a machine that rebuilds itself with no network must not fail at lock time with an error about a repository.

git+https:// rather than github:, for it and for its two. The github: fetcher goes through api.github.com — rate-limited per address, and a second service that has to be reachable. A git fetch over https is the protocol the repository speaks.

Consequences

  • tacid secureboot says which of four states a machine is in and what the next step is; keys and enrol move it. :secureboot in the interface does the same, straight to the keeper.
  • The firmware’s two variables are read once, in tacid_facts::machine::efi_flag, and setup_mode joins secure_boot in system.boot. The doctor’s remedy now says which state the firmware is in rather than naming a state the person has to go and check.
  • Operation::SecureBootKeys is new and SecureBootEnrol lost its argument: twenty-two operations, and the exhaustive test says so.
  • checks.<system>.secureboot proves the gate on two machines that differ in one option: the unsigned one is refused before the stem hears of it, and the signing one gets past every check Tacid makes and is stopped by the firmware. It does not prove the firmware coming to verify — that needs OVMF in setup mode and a disk image signed before it was written — and the file says so rather than implying more than it shows.
  • Every machine’s lock gains three entries. Every machine’s first build of a Secure Boot generation downloads a Rust toolchain and builds a UEFI stub; a machine that leaves the option off builds neither.

Enrolling breaks the root’s seal, and this record does not yet say what to do about it. The LUKS root key is sealed to PCR 7 and to nothing else, because PCR 7 is the Secure Boot state and kernel PCRs would lock an owner out on the first hourly update (ADR-0012, ADR-0039). Enrolling keys changes the Secure Boot state, so it changes PCR 7, so the seal made before it stops unsealing: the next boot asks for the PIN, fails, and falls back to the passphrase or the recovery key. That is not a brick — the machine still opens for somebody who has the recovery key shown to them once at installation — but it is a machine that silently stops using its TPM, and nothing here warns the person about to cause it.

There are two honest answers and they are not the same answer. Enrol before sealing, at installation, when the person is present and the disk is empty and enrolment is at its least frightening — but then it is an installation-time choice and Secure Boot is opt-in until the hardware matrix passes. Or re-seal after enrolling, which tacid secureboot enrol could do, and which needs the volume unlocked by something other than the token it is about to replace, so it asks for the recovery key at that moment. Which of those is right is a decision, not a detail, and it is the reason nix/tests/secureboot.nix and nix/tests/install.nix have not been joined. It belongs in a record that supersedes this one rather than in a comment.

Alternatives rejected

Lanzaboote’s autoEnrollKeys. It works, and it is safe — it writes the authenticated variables to the EFI partition, re-signs everything, and lets systemd-boot enrol at the next boot when the firmware is in setup mode. It is also a unit doing the irreversible act to somebody who asked for a signed boot chain and not for that. tacid secureboot enrol is a person.

Lanzaboote’s autoGenerateKeys. It reads as a kindness and it is the opposite. The unit runs at multi-user.target of a machine that already booted, so the machine that would need it — one whose activation refused to install unsigned artifacts — never reaches it. What it would actually catch is a machine that lost its keys, and there it would quietly make different ones and sign the next generation with keys the firmware does not trust. Losing them is a thing to say out loud.

allowUnsigned = true, which is Lanzaboote’s default when keys are generated automatically. It makes the first activation succeed on a machine with no keys, at the cost of an EFI partition that is unsigned and a signing fact that is a lie for one generation. A failed activation is loud, leaves the previous generation exactly where it was, and names the missing keys. Loud and recoverable beats quiet and wrong, and this is the one place where quiet and wrong is a brick.

Verify the partition before enrolling, with sbctl verify. The obvious check, and it does not work: sbctl verify reports unsigned files and exits zero, so a step that ran it would pass on exactly the machine it exists to stop. Parsing its JSON in the stem would put a signature check in the process that must stay a list of typed operations. The invariant is better held by the module — an activation that cannot sign fails — than by a check that runs afterwards.

Write our own signed unified images with ukify and sbsign, and drop the input. systemd’s own stub packs the initrd into the image, so every generation on the EFI partition is a full copy; a machine keeping the last several would need an EFI partition sized for it. Lanzaboote’s stub loads the initrd separately and verifies its hash, which is the whole reason it exists. Writing our own would be re-deriving that, badly, to avoid two entries in a lock file. Nix is the engine and Tacid is the face (ADR-0001); the same answer applies here.

Ship Secure Boot on by default. ADR-0012 already said opt-in until the certified matrix passes, and nothing here changes that. A default that bricks one machine in fifty is not a default.