Visitar URL original
Initialize systemd's NvPCRs in the initrd by pilotstew · Pull Request #437 · anatol/booster · GitHub
Skip to content

Initialize systemd's NvPCRs in the initrd - #437

Open
pilotstew wants to merge 12 commits into
anatol:masterfrom
pilotstew:pr/nvpcr-initrd
Open

pilotstew wants to merge 12 commits into
anatol:masterfrom
pilotstew:pr/nvpcr-initrd

Conversation

@pilotstew

@pilotstew pilotstew commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

systemd 262 moved the first write of each NvPCR into the initrd, so on a booster boot the host's NvPCR units fail and the hardware, cryptsetup, login and verity NvPCRs stay unusable. This does in booster what systemd-tpm2-setup --early and systemd-pcrnvdone do in a systemd initrd.

  • The generator bundles the host's NvPCR definitions into /usr/lib/nvpcr, on systemd 262 and later only. Versions 259 to 261 initialize NvPCRs on the host with an anchor secret instead.
  • On a measured UKI boot, init defines each NvPCR, does its first write under the UKI's "initrd" PCR signature, or bound to the current PCR 11 when there is none (as systemd 263 does), writes the .auth file the host extends with, and records each one plus a separator in PCR 9. systemd.tpm2_measured_os= overrides the default.
  • TPM unlocks, the PCR 15 latch, the leave-initrd extend and resume wait for the setup, since a PCR extend in between fails the session. The wait gives up after 30 seconds.
  • The signature is verified once per setup and the ticket reused, which halved the setup on a Nuvoton dTPM (2.59 s to 1.27 s).
  • PCR signature entries are now matched by policy reference, which the "initrd" entry from ukify's SignInitrdPCRs=yes needs.

Testing:

  • swtpm unit tests for both first-write branches, index reuse and replacement, NV space exhaustion, the ticket reuse, and the waits.
  • QEMU boots with systemd.tpm2_measured_os=1 that check the setup finishes before a systemd-tpm2 unlock, a clevis TPM2 unlock and a resume. The hibernate test keeps one swtpm state across both boots and extends an NvPCR after resume.
  • Real systemd 261 and 262 binaries against booster-initialized NvPCRs on swtpm, in both directions.
  • A Nuvoton NPCT75x laptop on both the signed and the PCR 11 path: no failed units, and the host extends hardware and login.

This is stacked on the open TPM wait PR; its three commits come first.

let me know what you think @enihcam

@pilotstew pilotstew changed the title init: initialize systemd's NvPCRs in the initrd Initialize systemd's NvPCRs in the initrd Oct 15, 2026
@enihcam

enihcam commented Oct 15, 2026

Copy link
Copy Markdown

LGTM.

A couple of small questions while reading the sync and the signed first-write path:

startNvPCRSetup() runs after udevListener is already going. On a machine where /dev/tpmrm0 is already there (built-in driver), could a TPM unlock call awaitNvPCRSetup() before the WaitGroup has been Added, see a zero counter, and continue while setup is still about to start? Would it be safer to Add(1) before starting udev (or use a latch that starts “not ready”)?

For LUKS signed unseal you still try RSA exponents 0x10001 and then 0, but NvPCR init seems to use only key.E. Is that enough on every TPM you care about, or is the second exponent only needed for the unseal path?

@pilotstew

Copy link
Copy Markdown
Collaborator Author

You're absolutely right. I missed it and looking into it I found a similar but unrelated bug I'll address in another PR. The fix starts setup before udev.

On the exponent: the 0 retry on unseal is for tokens from systemd versions that stored the default RSA exponent as 0 (systemd issue 30546); systemd's own unseal does the same retry. NvPCR indexes are new in 262, and systemd encodes 0x10001 when it builds them, so key.E matches. I checked that booster and systemd 262 produce byte-identical index Names and .auth from the same key. If a Name ever differed, booster would redefine the index rather than fail.

@enihcam

enihcam commented Oct 18, 2026

Copy link
Copy Markdown

Two things I am not sure you have already seen:

  1. On a modular TPM, /dev/tpmrm0 is often missing when setup starts. tpmReadyWg is still zero, so tpmAwaitReady() returns immediately, openTPM fails, and setup calls Done() and never retries. Later the module loads and unlock works, but the NvPCRs stay uninitialized. Is this the bug you plan to fix in the other PR, or a separate case?

  2. If initNvPCRs fails, or nvpcrBrokenTPM skips the indexes, setupNvPCRs still extends nvpcr-separator into PCR 9. The PCR then looks like systemd-pcrnvdone finished, but the indexes and .auth files are missing. Should the separator be extended only after a successful init?

Also, tpmAwaitReady() == false returns with no warning, so a skipped setup is easy to miss in the log. A one-line warning there would help.

@pilotstew

Copy link
Copy Markdown
Collaborator Author
  1. You're correct again (thank you). I missed it and my -race stress tests didn't expose it. The TPM wait was armed inside the udev listener, which starts after the setup. I reproduced it on the Void kernel (tpm_tis as a module) and the wait is now armed before either starts.
    I also found that with plymouth enabled the driver is only requested after plymouth starts, which can exceed the 3 second wait. The setup now waits for plymouth before it waits for the TPM, and warns when it finds no TPM.

  2. This was based on systemd. Lennart's commit message adding pcrnvdone says a failed NvPCR init is acceptable as long as the separator marks where it would have happened.
    One difference: pcrnvdone has FailureAction=reboot-force, so systemd reboots if the separator itself cannot be measured, while booster only warns (as it does for its other PCR extends). A missing separator already changes PCR 9, so tokens bound to it fail closed anyway.
    My general stance is to mirror systemd unless I believe booster can do better and understand the consequences (or can't mirror it). The more we drift, the more work it can be down the road. That said, if you have a well reasoned suggestion I'm open to it.

@enihcam

enihcam commented Oct 21, 2026

Copy link
Copy Markdown

LGTM

tpmAwaitReady waits for the uevent that announces /dev/tpmrm0. When the
TPM driver is built into the kernel, the device is created before
booster starts listening for uevents, so the event never comes and
every TPM2 unlock waits out the full 3 second timeout.

Return right away when /dev/tpmrm0 already exists.
Before unsealing a signed TPM2 token bound to PCR 11, booster extends
PCR 11 with "enter-initrd". That step opened the TPM without waiting for
it; only the unseal waited. When the TPM driver is a module, the disk
can appear before the module is loaded, so the extend fails with "stat
/dev/tpmrm0: no such file or directory" and booster falls back to the
passphrase prompt.

Move the wait from the unseal to the start of systemd-tpm2 token
handling, so it covers the extend, the PIN prompt and both unseal
paths. If no TPM appears, fail the token there instead of asking for a
PIN.
The Void Linux test kernel builds tpm_tis as a module and NVMe into the
kernel. With the disk on NVMe, booster finds the LUKS device before the
TPM exists, which is the case the previous commit fixes. The kernel the
other tests use has the TPM driver built in, so it cannot hit this case.
systemd-measure --policyref signs the policy digest followed by
SHA256 of the reference, and ukify's SignInitrdPCRs=yes adds such an
"initrd" entry next to the plain one. Booster ignored the ref, so it
could pick the "initrd" entry for a plain token and fail to verify it.

Select entries by ref as systemd does, include the ref in the signed
data and PolicyAuthorize, and honour tpm2_pubkey_ref on tokens enrolled
with --tpm2-public-key-policyref.
systemd 262 only initializes NvPCRs in the initrd, so init needs the
definitions systemd would load on the host. Copy the winning copy of
each /usr/lib/nvpcr, /usr/local/lib/nvpcr and /etc/nvpcr definition
into /usr/lib/nvpcr, skipping masked names.

Do this only for systemd 262 and later. Versions 259 to 261 initialize
NvPCRs on the host with an anchor secret and reject an index defined
the 262 way.
systemd 262 only allows the first write to an NvPCR in the initrd, so
on a booster boot the host's NvPCR units fail. On a measured UKI boot,
define each bundled NvPCR, do its first write under the UKI's "initrd"
signature or else bound to the current PCR 11 as systemd 263 does,
write the .auth file the host extends with, and record it in PCR 9.

go-tpm has no NV_Extend command type and keeps its executor private,
so the command is marshaled by hand. TPMs that hang on NV_Extend are
skipped like systemd's hwdb quirk, and systemd.tpm2_measured_os= is
honoured.
An extend of PCR 0 to 15 between a PolicyPCR and the command that uses
the session fails that command with TPM_RC_PCR_CHANGED. The PCR 15
latch, the leave-initrd extend and the TPM2 and clevis unlocks now wait
for the NvPCR setup, as systemd orders it before cryptsetup. Resume
waits too, since the resumed kernel uses the NvPCRs this boot sets up.

The wait gives up after 30 seconds, so a TPM that hangs in NV_Extend
delays the boot instead of stopping it.
The generator bundles the host's NvPCR definitions, so on systemd 262
this boots with systemd.tpm2_measured_os=1 and expects the NvPCRs and
the PCR 9 separator to be set up before each TPM user runs: a
systemd-tpm2 unlock, a clevis TPM2 unlock and a resume.

The hibernate test now keeps one swtpm state across both boots, as
hibernating powers the TPM off, and checks that the resumed system can
still extend its NvPCRs after the resume boot sets them up again.
Document systemd.tpm2_measured_os= and note that a measured UKI boot
initializes systemd's NvPCRs, with SignInitrdPCRs=yes keeping their
names stable across kernel updates.
Every NvPCR's first write loaded the PCR key and had the TPM verify the
"initrd" signature again, about 500 ms per index on a Nuvoton dTPM. All
writes in one setup approve the same PCR 11 digest, so the first ticket
now satisfies PolicyAuthorize for the rest.

The ticket only attests that the key signed the digest. Each session
still runs PolicyPCR against the live PCR 11, and the TPM rejects the
ticket once PCR 11 moves, which a test pins.
The setup registered itself only after the udev listener started. A disk
that appeared while force-loaded modules were loading could reach a TPM
unlock, the PCR 15 latch or resume before that, find no setup to wait
for, and run ahead of it. Starting the setup first closes the window.

The signed unseal retries RSA exponent 0 for tokens from systemd
versions that stored the default exponent that way, as systemd's own
unseal does. The NvPCR policy needs no such retry: systemd builds it
from the PEM exponent unchanged and NvPCRs are new in 262, so key.E
matches what systemd writes.
tpmReadyWg was armed inside udevListener, which starts after the NvPCR
setup, so on a modular TPM the setup could wait on a zero counter, fail
to open /dev/tpmrm0 and give up. Arm it in boost before either starts.

Even armed, the 3 second wait started at boot, while the driver is only
requested after plymouth has started, which can take up to 5 seconds.
Start the wait once plymouth is up, and warn when no TPM is found.
@anatol

anatol commented Oct 26, 2026

Copy link
Copy Markdown
Owner

@pilotstew @enihcam is it reviewed/settled? are there any objections to merge it?

@pilotstew

Copy link
Copy Markdown
Collaborator Author

It's ready but based on top of #434. Which should also be good to go.

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.

3 participants