Skip to content

init: fix TPM2 integrity check failure for systemd v252+ enrolled tokens (closes #233)#342

Merged
anatol merged 2 commits into
anatol:masterfrom
pilotstew:pr/tpm2-systemd-compat
Apr 27, 2026
Merged

init: fix TPM2 integrity check failure for systemd v252+ enrolled tokens (closes #233)#342
anatol merged 2 commits into
anatol:masterfrom
pilotstew:pr/tpm2-systemd-compat

Conversation

@pilotstew
Copy link
Copy Markdown
Contributor

Users who enrolled with systemd-cryptenroll v252 or later see:

recovering systemd-tpm2 token failed: clevis.go/tpm2: unable to load data: integrity check failed

Two changes in systemd broke compatibility:

1. Persistent SRK (v252+)

systemd-cryptenroll v252 began provisioning a persistent SRK at handle 0x81000001 via tpm2_get_or_create_srk and recording it in the LUKS2 token JSON as tpm2_srk. Booster was always deriving a transient primary via CreatePrimary. When a persistent SRK was used during enrollment, loading the sealed object against a freshly derived primary produces a different parent key, causing the integrity check to fail.

The fix avoids the IESYS_RESOURCE deserialization problem noted in #233 — we only need the TPM2_HANDLE embedded in the IESYS header (bytes 6–9, big-endian), which maps directly to a tpmutil.Handle. When tpm2_srk is present, tpm2.Load is called against the persistent handle. FlushContext is intentionally skipped on the persistent handle (it would evict it from the TPM). Pre-v252 tokens with no tpm2_srk continue to use the old CreatePrimary path.

2. PBKDF2 PIN auth (v255+)

systemd-cryptenroll v255 added a tpm2_salt field to PIN-protected tokens. The old formula was authValue = SHA256_trimmed(pin); the new formula is SHA256_trimmed(base64(PBKDF2-HMAC-SHA256(pin, salt, 10000, 32))). Tokens without tpm2_salt continue to use the old path.

Coverage

Four QEMU integration tests cover all relevant paths:

Token format Test
Pre-v252 (transient primary, no SRK) TestSystemdTPM2 (existing)
v252+ (persistent SRK, no PIN) TestSystemdTPM2SRK (new)
v255+ (persistent SRK + PBKDF2 PIN) TestSystemdTPM2WithPin (existing)
v252–254 (persistent SRK, SHA256 PIN, no PBKDF2) TestSystemdTPM2LegacyPin (new)

The legacy-PIN test uses raw tpm2-tools to construct the token independently of the installed systemd version and is skipped if tpm2-tools are absent.

Known limitation

The TPM2 PIN prompt does not yet include the mapping name and has no retry loop on wrong PIN. The FIDO2 path gained both (mapping name in prompt, 3-attempt retry with progressive feedback) in a recent PR; parity for TPM2 is planned as a follow-up.

…and PBKDF2 PIN)

systemd-cryptenroll v252 began provisioning a persistent SRK at handle
0x81000001 and recording it as tpm2_srk in the LUKS2 token JSON.  The
old code always derived a transient primary via CreatePrimary using a
well-known ECC template.  When tpm2_srk is present, tpm2.Load must be
called against the persistent handle — using a freshly derived primary
produces a different parent key, causing the integrity check to fail.

systemd v255 added a PBKDF2 salt (tpm2_salt) to PIN-protected tokens.
The old code computed authValue = SHA256_trimmed(pin); the new formula
is SHA256_trimmed(base64(PBKDF2-HMAC-SHA256(pin, salt, 10000, 32))).
Tokens without tpm2_salt continue to use the old path.

Changes:
- extractSRKHandle: parses the 10-byte IESYS_RESOURCE_SERIALIZE header
  that systemd writes into tpm2_srk; falls back to 0x81000001 on any
  parse failure so pre-v252 tokens (no tpm2_srk field) still work.
- tpm2PINAuthValue: centralises PIN-to-authValue derivation for both
  the salted (v255+) and unsalted (pre-v255) paths.
- tpm2Unseal: accepts a srkHandle parameter; when non-zero it loads
  against the persistent handle directly without calling CreatePrimary
  (and without FlushContext, which would evict a persistent object).
- recoverSystemdTPM2Password: reads tpm2_salt and tpm2_srk from the
  token JSON and passes them through the updated helpers.

Note: the PIN prompt does not yet include the mapping name and falls
back to the keyboard on the first wrong PIN with no retry.  The FIDO2
path gained both of these (mapping name in prompt, 3-attempt retry
with progressive feedback) during recent work; parity for the TPM2
PIN path is planned for a follow-up PR.
TestSystemdTPM2SRK: unlocks a LUKS2 volume enrolled with systemd
v252+, which provisions a persistent SRK at 0x81000001 and records it
as tpm2_srk in the token JSON.  Exercises the new persistent-handle
path in tpm2Unseal.

TestSystemdTPM2LegacyPin: unlocks a LUKS2 volume whose token was
constructed to match the v252-254 format — tpm2_srk present but no
tpm2_salt, so PIN auth uses SHA256_trimmed(pin) without PBKDF2.
Generated with raw tpm2-tools rather than systemd-cryptenroll to be
independent of the installed systemd version; skipped when tpm2-tools
are absent.

Generator/infrastructure changes:
- systemd_tpm2.sh: wait for swtpm readiness before enrolling (prevents
  fallback to /dev/tpm0 on hosts with a real TPM), use explicit TCTI
  string, save swtpm state back to pristine on success so the
  provisioned SRK persists across test runs.
- swtpm.sh: fix ownership of state directory after swtpm_setup; add
  optional SRK pre-provisioning when tpm2-tools are available.
- systemd_tpm2_legacy_pin.sh: new generator; builds the v252-254
  format token using tpm2-tools and Python JSON construction.
- Both generators use mktemp for temporary files to avoid collisions.
@anatol anatol merged commit 8ed7523 into anatol:master Apr 27, 2026
@anatol
Copy link
Copy Markdown
Owner

anatol commented Apr 27, 2026

Fantastic improvement! Thank you again.

@pilotstew pilotstew deleted the pr/tpm2-systemd-compat branch May 9, 2026 02:53
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.

2 participants