From 11fffbb9c89a683e4bf2b9607bb90d882c2bac78 Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 22 Sep 2026 11:59:38 +0000 Subject: [PATCH 1/5] cryptsetup-tpm2: add log_level parameter to find_tpm2_auto_data() find_tpm2_auto_data() unconditionaly logs at LOG_ERR/LOG_INFO. That is fine for its current callers, systemd-cryptsetup and systemd-cryptenroll, but not for library code in src/shared/, which must not log above LOG_DEBUG. A folow-up wants to call it from dissect-image.c. Let callers pass the log level to use. The existing callers pass LOG_ERR, so nothing changes for them. The "Automatically discovered security TPM2 token unlocks volume." message uses MAX(log_level, LOG_INFO): it stays at LOG_INFO for them and drops to LOG_DEBUG for quiet callers. --- src/cryptenroll/cryptenroll-tpm2.c | 1 + src/cryptsetup/cryptsetup.c | 1 + src/shared/cryptsetup-tpm2.c | 14 +++++++++----- src/shared/cryptsetup-tpm2.h | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cryptenroll/cryptenroll-tpm2.c b/src/cryptenroll/cryptenroll-tpm2.c index 21b77bd9c04..c5613cd7d10 100644 --- a/src/cryptenroll/cryptenroll-tpm2.c +++ b/src/cryptenroll/cryptenroll-tpm2.c @@ -206,6 +206,7 @@ int load_volume_key_tpm2( cd, UINT32_MAX, token, + LOG_ERR, &hash_pcr_mask, &pcr_bank, &pubkey, diff --git a/src/cryptsetup/cryptsetup.c b/src/cryptsetup/cryptsetup.c index fb616a3426c..893a516f2c0 100644 --- a/src/cryptsetup/cryptsetup.c +++ b/src/cryptsetup/cryptsetup.c @@ -2041,6 +2041,7 @@ static int attach_luks_or_plain_or_bitlk_by_tpm2( cd, arg_tpm2_pcr_mask, /* if != UINT32_MAX we'll only look for tokens with this PCR mask */ token, /* search for the token with this index, or any later index than this */ + LOG_ERR, &hash_pcr_mask, &pcr_bank, &pubkey, diff --git a/src/shared/cryptsetup-tpm2.c b/src/shared/cryptsetup-tpm2.c index e989c3c5fde..ad8a99ce792 100644 --- a/src/shared/cryptsetup-tpm2.c +++ b/src/shared/cryptsetup-tpm2.c @@ -297,6 +297,7 @@ int find_tpm2_auto_data( struct crypt_device *cd, uint32_t search_pcr_mask, int start_token, + int log_level, uint32_t *ret_hash_pcr_mask, uint16_t *ret_pcr_bank, struct iovec *ret_pubkey, @@ -355,7 +356,7 @@ int find_tpm2_auto_data( if (ERRNO_IS_NEG_CRYPTSETUP_TOKEN_SKIP(r)) continue; if (r < 0) - return log_error_errno(r, "Failed to read JSON token data off disk: %m"); + return log_full_errno(log_level, r, "Failed to read JSON token data off disk: %m"); r = tpm2_parse_luks2_json( v, @@ -378,13 +379,16 @@ int find_tpm2_auto_data( if (r == -EUCLEAN) /* Gracefully handle issues in JSON fields not owned by us */ continue; if (r < 0) - return log_error_errno(r, "Failed to parse TPM2 JSON data: %m"); + return log_full_errno(log_level, r, "Failed to parse TPM2 JSON data: %m"); if (search_pcr_mask == UINT32_MAX || search_pcr_mask == hash_pcr_mask) { if (start_token <= 0) - log_info("Automatically discovered security TPM2 token unlocks volume."); + /* Syslog priorities count the other way round: the higher the number, the + * less severe. Hence MAX() keeps this at LOG_INFO for callers passing + * LOG_ERR, and lowers it to LOG_DEBUG for callers requesting quiet operation. */ + log_full(MAX(log_level, LOG_INFO), "Automatically discovered security TPM2 token unlocks volume."); *ret_hash_pcr_mask = hash_pcr_mask; *ret_pcr_bank = pcr_bank; @@ -410,8 +414,8 @@ int find_tpm2_auto_data( /* PCR mask doesn't match what is configured, ignore this entry, let's see next */ } - return log_error_errno(SYNTHETIC_ERRNO(ENXIO), "No valid TPM2 token data found."); + return log_full_errno(log_level, SYNTHETIC_ERRNO(ENXIO), "No valid TPM2 token data found."); #else - return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "TPM2 support not available."); + return log_full_errno(log_level, SYNTHETIC_ERRNO(EOPNOTSUPP), "TPM2 support not available."); #endif } diff --git a/src/shared/cryptsetup-tpm2.h b/src/shared/cryptsetup-tpm2.h index 6d0370847d2..d5272b32254 100644 --- a/src/shared/cryptsetup-tpm2.h +++ b/src/shared/cryptsetup-tpm2.h @@ -37,6 +37,7 @@ int find_tpm2_auto_data( struct crypt_device *cd, uint32_t search_pcr_mask, int start_token, + int log_level, uint32_t *ret_hash_pcr_mask, uint16_t *ret_pcr_bank, struct iovec *ret_pubkey, From 3520b44f7e398f924c267ee4527941924297c6bc Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 13 Oct 2026 10:29:12 +0000 Subject: [PATCH 2/5] tpm2-util: refuse tokens with public key policy reference but no key tpm2_parse_luks2_json() only insists on "tpm2_pubkey" being present when "tpm2_pubkey_pcrs" is set, but happily accepts a token carrying "tpm2_pubkey_ref" without a public key. tpm2_unseal() then trips over its assert(!pubkey_policy_ref || iovec_is_set(pubkey)), i.e. a crafted LUKS2 header aborts whatever tries to unlock it instead of being rejected. tpm2_make_luks2_json() never writes that combination (it asserts the same invariant), hence refuse it when parsing, as is already done for a PCR mask without a public key. This matters more now that systemd-dissect, systemd-nspawn and systemd-sysext unlock images via these tokens, as the headers they see are less trusted than the host's own volumes. --- src/shared/tpm2-util.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/tpm2-util.c b/src/shared/tpm2-util.c index f003b336135..44c2c217205 100644 --- a/src/shared/tpm2-util.c +++ b/src/shared/tpm2-util.c @@ -11934,6 +11934,8 @@ int tpm2_parse_luks2_json( const char *s = sd_json_variant_string(w); if (!s) return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "Public key policy reference is not a string."); + if (!iovec_is_set(&pubkey)) + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "Public key policy reference set, but no public key included in JSON data, refusing."); pubkey_ref = strdup(s); if (!pubkey_ref) return log_oom_debug(); From 972ceb2258f432393a5ce8225755668fcb9979d2 Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 22 Sep 2026 11:59:54 +0000 Subject: [PATCH 3/5] dissect-image: unlock LUKS partitions via enrolled TPM2 tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decrypt_partition() only ever activated a LUKS partition with a literal passphrase; a systemd-tpm2 LUKS2 token in the header, as enrolled by systemd-cryptenroll, was never considered, even though systemd-cryptsetup has been able to unlock such tokens for a long time. Hence a DDI whose root partition is sealed to the local TPM2 could not be mounted without somebody typing a passphrase. Add cryptsetup_tpm2_unlock_auto(), a non-interactive, debug-logging-only counterpart to acquire_tpm2_key(): it walks the systemd-tpm2 tokens, skips PIN-protected ones, loads PCR signature and pcrlock policy where needed, unseals via tpm2_unseal() and returns the secret base64-encoded together with its keyslot. The secret is used as that keyslot's passphrase, since that is how it is enrolled; it is not the volume key. Add DISSECT_IMAGE_TPM2_UNLOCK. When set and no passphrase was supplied, decrypt_partition() tries the TPM2 instead of failing with -ENOKEY right away. If that doesn't work out — no TPM2, no usable token, policy mismatch, or the unsealed secret not matching its keyslot — it still returns -ENOKEY, exactly as if no token existed, so interactive callers prompt for a passphrase as before and retry with it. A supplied passphrase is always used exclusively, the TPM2 is not consulted then. Hence the TPM2 is tried at most once per unlock, and passphrase-only images never touch it, since the TPM2 is only opened once a usable token was found. Like systemd-cryptsetup we don't try further tokens if the unsealed secret is rejected by its keyslot; other activation errors such as -EEXIST propagate as before. The flag is opt-in since dissected_image_decrypt() is also used by systemd-mountfsd on behalf of unprivileged clients. Nothing sets it yet. $SYSTEMD_DISSECT_TPM2_UNLOCK=0 turns it off again; for the same reason the environment cannot turn it on. Since dissect-image.c now references the TPM2 code, every tool that links it gains the libtss2 dlopen() dependency, which shows up in the ELF dlopen notes of their .standalone builds. Declare the matching TPM2_NOTE() in those tools as well (bootctl and sysupdate only lacked the tcti-device one), so that the dlopen-note test keeps passing. --- src/bootctl/bootctl.c | 4 +- src/coredump/coredumpctl.c | 1 + src/dissect/dissect.c | 1 + src/firstboot/firstboot.c | 1 + src/journal/journalctl.c | 1 + src/kernel-install/kernel-install.c | 1 + src/machine/machined.c | 1 + src/mountfsd/mountwork.c | 1 + src/mstack/mstack-tool.c | 1 + src/nspawn/nspawn.c | 1 + src/portable/portabled.c | 1 + src/shared/cryptsetup-tpm2.c | 156 ++++++++++++++++++++++++++++ src/shared/cryptsetup-tpm2.h | 5 + src/shared/dissect-image.c | 56 ++++++++-- src/shared/dissect-image.h | 1 + src/sysext/sysext.c | 1 + src/sysinstall/sysinstall.c | 1 + src/systemctl/systemctl-main.c | 1 + src/sysupdate/sysupdate.c | 4 +- src/sysusers/sysusers.c | 1 + src/tmpfiles/tmpfiles.c | 1 + 21 files changed, 227 insertions(+), 14 deletions(-) diff --git a/src/bootctl/bootctl.c b/src/bootctl/bootctl.c index b345c6a6b8c..465f709e1a6 100644 --- a/src/bootctl/bootctl.c +++ b/src/bootctl/bootctl.c @@ -801,9 +801,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(recommended); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); - LIBTSS2_ESYS_NOTE(suggested); - LIBTSS2_MU_NOTE(suggested); - LIBTSS2_RC_NOTE(suggested); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/coredump/coredumpctl.c b/src/coredump/coredumpctl.c index 14f2b33b9cc..06275b9faaf 100644 --- a/src/coredump/coredumpctl.c +++ b/src/coredump/coredumpctl.c @@ -295,6 +295,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); + TPM2_NOTE(suggested); setlocale(LC_ALL, ""); log_setup(); diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index eb113f4d004..7056b7b1d15 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -1970,6 +1970,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c index 5068a28f53c..d35538e7680 100644 --- a/src/firstboot/firstboot.c +++ b/src/firstboot/firstboot.c @@ -1736,6 +1736,7 @@ static int run(int argc, char *argv[]) { LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); PASSWORD_NOTE(suggested); + TPM2_NOTE(suggested); r = parse_argv(argc, argv); if (r <= 0) diff --git a/src/journal/journalctl.c b/src/journal/journalctl.c index 2023e3bf287..41d11a3543a 100644 --- a/src/journal/journalctl.c +++ b/src/journal/journalctl.c @@ -1012,6 +1012,7 @@ static int run(int argc, char *argv[]) { LIBMOUNT_NOTE(recommended); LIBPCRE2_NOTE(suggested); LIBQRENCODE_NOTE(suggested); + TPM2_NOTE(suggested); setlocale(LC_ALL, ""); log_setup(); diff --git a/src/kernel-install/kernel-install.c b/src/kernel-install/kernel-install.c index 0fb3a4034e0..905f8d236a9 100644 --- a/src/kernel-install/kernel-install.c +++ b/src/kernel-install/kernel-install.c @@ -1740,6 +1740,7 @@ static int run(int argc, char* argv[]) { LIBCRYPTO_NOTE(suggested); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/machine/machined.c b/src/machine/machined.c index 66d128c15b3..ee84ef60604 100644 --- a/src/machine/machined.c +++ b/src/machine/machined.c @@ -365,6 +365,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); log_set_facility(LOG_AUTH); log_setup(); diff --git a/src/mountfsd/mountwork.c b/src/mountfsd/mountwork.c index af84ace1eb9..c5adac7852b 100644 --- a/src/mountfsd/mountwork.c +++ b/src/mountfsd/mountwork.c @@ -1494,6 +1494,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/mstack/mstack-tool.c b/src/mstack/mstack-tool.c index a8d38d91644..455d2b1a033 100644 --- a/src/mstack/mstack-tool.c +++ b/src/mstack/mstack-tool.c @@ -373,6 +373,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c index 988184a6631..4709928384b 100644 --- a/src/nspawn/nspawn.c +++ b/src/nspawn/nspawn.c @@ -6114,6 +6114,7 @@ static int run(int argc, char *argv[]) { LIBMOUNT_NOTE(recommended); LIBSECCOMP_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); (void) dlopen_cryptsetup(LOG_DEBUG); (void) dlopen_libacl(LOG_DEBUG); (void) dlopen_libblkid(LOG_DEBUG); diff --git a/src/portable/portabled.c b/src/portable/portabled.c index e42ffed4d4f..4ac85b1870e 100644 --- a/src/portable/portabled.c +++ b/src/portable/portabled.c @@ -161,6 +161,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTSETUP_NOTE(recommended); LIBMOUNT_NOTE(required); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/shared/cryptsetup-tpm2.c b/src/shared/cryptsetup-tpm2.c index ad8a99ce792..e1ba115dbf3 100644 --- a/src/shared/cryptsetup-tpm2.c +++ b/src/shared/cryptsetup-tpm2.c @@ -419,3 +419,159 @@ int find_tpm2_auto_data( return log_full_errno(log_level, SYNTHETIC_ERRNO(EOPNOTSUPP), "TPM2 support not available."); #endif } + +int cryptsetup_tpm2_unlock_auto( + struct crypt_device *cd, + int *ret_keyslot, + char **ret_passphrase) { + +#if HAVE_LIBCRYPTSETUP && HAVE_TPM2 + _cleanup_(sd_json_variant_unrefp) sd_json_variant *signature_json = NULL; + _cleanup_(tpm2_context_unrefp) Tpm2Context *tpm2_context = NULL; + bool found_some = false, signature_load_failed = false; + int r; + + assert(cd); + assert(ret_keyslot); + assert(ret_passphrase); + + /* Non-interactive counterpart to acquire_tpm2_key(), for use from library code. Walks all + * "systemd-tpm2" tokens in the LUKS2 header and returns the first one that unseals against the + * local TPM2 (auto-detected), as the base64-encoded keyslot passphrase (which is how + * systemd-cryptenroll and systemd-repart enroll the sealed secret) plus the keyslot it belongs to. + * PIN-protected tokens are skipped, we never prompt. Logs at debug level only, except that the + * pcrlock policy helpers log on their own for pcrlock-bound tokens. Returns -ENXIO if no token could + * be used. */ + + for (int token = 0;; token++) { + _cleanup_(iovec_done) struct iovec pubkey = {}, salt = {}, srk = {}, pcrlock_nv = {}; + _cleanup_(tpm2_pcrlock_policy_done) Tpm2PCRLockPolicy pcrlock_policy = {}; + _cleanup_(iovec_done_erase) struct iovec secret = {}; + _cleanup_(erase_and_freep) char *b64 = NULL; + _cleanup_free_ char *pubkey_policy_ref = NULL; + struct iovec *blobs = NULL, *policy_hash = NULL; + size_t n_blobs = 0, n_policy_hash = 0; + uint32_t hash_pcr_mask, pubkey_pcr_mask; + uint16_t pcr_bank, primary_alg; + TPM2Flags flags; + int keyslot; + + CLEANUP_ARRAY(blobs, n_blobs, iovec_array_free); + CLEANUP_ARRAY(policy_hash, n_policy_hash, iovec_array_free); + + r = find_tpm2_auto_data( + cd, + /* search_pcr_mask= */ UINT32_MAX, + token, + LOG_DEBUG, + &hash_pcr_mask, + &pcr_bank, + &pubkey, + &pubkey_policy_ref, + &pubkey_pcr_mask, + &primary_alg, + &blobs, + &n_blobs, + &policy_hash, + &n_policy_hash, + &salt, + &srk, + &pcrlock_nv, + &flags, + &keyslot, + &token, + /* ret_argon2id_params= */ NULL); + if (r == -ENXIO) /* No (more) TPM2 tokens in the header */ + break; + if (r < 0) + return r; + + if (flags & (TPM2_FLAGS_USE_PIN|TPM2_FLAGS_USE_ARGON2ID)) { + log_debug("TPM2 token %i requires a PIN (or Argon2id), skipping.", token); + continue; + } + + found_some = true; + + if (pubkey_pcr_mask != 0) { + /* The signature file is the same for all tokens, hence load it only once, and don't + * retry if that failed. */ + if (!signature_json && !signature_load_failed) { + r = tpm2_load_pcr_signature(/* path= */ NULL, &signature_json); + if (r == -ENOMEM) + return r; + if (r < 0) { + log_debug_errno(r, "Failed to load PCR signature, cannot use TPM2 tokens bound to a public key: %m"); + signature_load_failed = true; + } + } + if (!signature_json) + continue; + } + + if (FLAGS_SET(flags, TPM2_FLAGS_USE_PCRLOCK)) { + r = tpm2_pcrlock_policy_load(/* path= */ NULL, &pcrlock_policy); + if (r == 0) /* Not found? Then search among passed credentials */ + r = tpm2_pcrlock_policy_from_credentials(&srk, &pcrlock_nv, &pcrlock_policy); + if (r == -ENOMEM) + return r; + if (r < 0) + log_debug_errno(r, "Failed to load pcrlock policy for TPM2 token %i, skipping: %m", token); + else if (r == 0) + log_debug("No pcrlock policy found for TPM2 token %i, skipping.", token); + if (r <= 0) + continue; + } + + /* Only talk to the TPM2 once we know there's a token we could actually use */ + if (!tpm2_context) { + _cleanup_free_ char *device = NULL; + + r = tpm2_find_device_auto(&device); + if (r < 0) + return log_debug_errno(r, "Failed to find TPM2 device: %m"); + + r = tpm2_context_new(device, &tpm2_context); + if (r < 0) + return log_debug_errno(r, "Failed to create TPM2 context: %m"); + } + + r = tpm2_unseal(tpm2_context, + hash_pcr_mask, + pcr_bank, + &pubkey, + pubkey_policy_ref, + pubkey_pcr_mask, + signature_json, + /* pin= */ NULL, + FLAGS_SET(flags, TPM2_FLAGS_USE_PCRLOCK) ? &pcrlock_policy : NULL, + primary_alg, + blobs, + n_blobs, + policy_hash, + n_policy_hash, + &srk, + &secret); + if (ERRNO_IS_NEG_TPM2_TOKEN_MISMATCH(r)) { + log_debug_errno(r, "TPM2 token %i does not match current system state, skipping: %m", token); + continue; + } + if (r < 0) + return log_debug_errno(r, "Failed to unseal secret using TPM2: %m"); + + /* The sealed secret is enrolled base64-encoded as keyslot passphrase, for compat with homed */ + if (base64mem(secret.iov_base, secret.iov_len, &b64) < 0) + return -ENOMEM; + + *ret_keyslot = keyslot; + *ret_passphrase = TAKE_PTR(b64); + return 0; + } + + return log_debug_errno(SYNTHETIC_ERRNO(ENXIO), + found_some ? "No TPM2 token matching the current system state found in LUKS2 header." + : "No TPM2 token usable without user interaction enrolled in LUKS2 header."); +#else + return -EOPNOTSUPP; +#endif +} diff --git a/src/shared/cryptsetup-tpm2.h b/src/shared/cryptsetup-tpm2.h index d5272b32254..f3348f3d242 100644 --- a/src/shared/cryptsetup-tpm2.h +++ b/src/shared/cryptsetup-tpm2.h @@ -33,6 +33,11 @@ int acquire_tpm2_key( const Argon2IdParameters *argon2id_params, struct iovec *ret_decrypted_key); +int cryptsetup_tpm2_unlock_auto( + struct crypt_device *cd, + int *ret_keyslot, + char **ret_passphrase); + int find_tpm2_auto_data( struct crypt_device *cd, uint32_t search_pcr_mask, diff --git a/src/shared/dissect-image.c b/src/shared/dissect-image.c index 10ef86ed59d..9246f0745cc 100644 --- a/src/shared/dissect-image.c +++ b/src/shared/dissect-image.c @@ -21,6 +21,7 @@ #include "constants.h" #include "copy.h" #include "crypto-util.h" +#include "cryptsetup-tpm2.h" #include "cryptsetup-util.h" #include "device-private.h" #include "devnum-util.h" @@ -47,6 +48,7 @@ #include "json-util.h" #include "libmount-util.h" #include "loop-util.h" +#include "memory-util.h" #include "mkdir.h" #include "mount-util.h" #include "mountpoint-util.h" @@ -3020,7 +3022,7 @@ static int decrypt_partition( if (!streq(m->fstype, "crypto_LUKS")) return 0; - if (!passphrase) + if (!passphrase && !FLAGS_SET(flags, DISSECT_IMAGE_TPM2_UNLOCK)) return -ENOKEY; if (!FLAGS_SET(policy_flags, PARTITION_POLICY_ENCRYPTED)) @@ -3054,12 +3056,43 @@ static int decrypt_partition( if (r < 0) return log_debug_errno(r, "Failed to load LUKS metadata: %m"); - r = sym_crypt_activate_by_passphrase(cd, name, CRYPT_ANY_SLOT, passphrase, strlen(passphrase), - ((flags & DISSECT_IMAGE_DEVICE_READ_ONLY) || on_verity ? CRYPT_ACTIVATE_READONLY : 0) | - ((flags & DISSECT_IMAGE_DISCARD_ON_CRYPTO) && !on_verity ? CRYPT_ACTIVATE_ALLOW_DISCARDS : 0)); - if (r < 0) { - log_debug_errno(r, "Failed to activate LUKS device: %m"); - return r == -EPERM ? -EKEYREJECTED : r; + uint32_t activate_flags = ((flags & DISSECT_IMAGE_DEVICE_READ_ONLY) || on_verity ? CRYPT_ACTIVATE_READONLY : 0) | + ((flags & DISSECT_IMAGE_DISCARD_ON_CRYPTO) && !on_verity ? CRYPT_ACTIVATE_ALLOW_DISCARDS : 0); + + /* A supplied passphrase is used exclusively, the TPM2 is not consulted then, even if + * DISSECT_IMAGE_TPM2_UNLOCK is set. Interactive callers only supply one after the TPM2 attempt below + * failed and they prompted the user. */ + if (passphrase) { + r = sym_crypt_activate_by_passphrase(cd, name, CRYPT_ANY_SLOT, passphrase, strlen(passphrase), activate_flags); + if (r < 0) { + log_debug_errno(r, "Failed to activate LUKS device: %m"); + return r == -EPERM ? -EKEYREJECTED : r; + } + } else { + /* No passphrase, but TPM2 unlocking was requested (see check above): see if a TPM2 token + * enrolled in the LUKS2 header (e.g. via "systemd-cryptenroll --tpm2-device=…") unseals + * against the local TPM2. If not — no TPM2, no token, policy mismatch — this fails with + * -ENOKEY, exactly as if no TPM2 token existed, so that interactive callers prompt for a + * passphrase and retry with it. If the unsealed secret is rejected by its keyslot we don't + * try the remaining tokens, matching what systemd-cryptsetup does. */ + _cleanup_(erase_and_freep) char *tpm2_passphrase = NULL; + int keyslot; + + r = cryptsetup_tpm2_unlock_auto(cd, &keyslot, &tpm2_passphrase); + if (r == -ENOMEM) + return r; + if (r < 0) { + log_debug_errno(r, "Unable to unlock LUKS device via TPM2, and no passphrase supplied: %m"); + return -ENOKEY; + } + + r = sym_crypt_activate_by_passphrase(cd, name, keyslot, tpm2_passphrase, strlen(tpm2_passphrase), activate_flags); + if (r == -EPERM) { + log_debug_errno(r, "Secret unsealed via TPM2 does not unlock LUKS keyslot %i, token out of sync with keyslot: %m", keyslot); + return -ENOKEY; + } + if (r < 0) + return log_debug_errno(r, "Failed to activate LUKS device with TPM2-unsealed secret: %m"); } fd = open(node, O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_NOCTTY); @@ -3611,7 +3644,8 @@ int dissected_image_decrypt( * * = 0 → There was nothing to decrypt/setup * > 0 → Decrypted/setup successfully - * -ENOKEY → dm-crypt: there's something to decrypt but no decryption key was supplied + * -ENOKEY → dm-crypt: there's something to decrypt but no decryption key was supplied (or + * TPM2 unlocking was requested via DISSECT_IMAGE_TPM2_UNLOCK, but didn't succeed) * -EKEYREJECTED → dm-crypt: Passed key was not correct * -EDESTADDRREQ → dm-verity: there's something to setup but no signature was supplied * -EBUSY → dm-verity: Generic Verity error (kernel is not very explanatory) @@ -3629,6 +3663,12 @@ int dissected_image_decrypt( if (r >= 0) SET_FLAG(flags, DISSECT_IMAGE_VERITY_SHARE, r); + /* The environment may only turn TPM2 unlocking off, never on: callers such as systemd-mountfsd + * act on behalf of unprivileged clients and must not be talked into unsealing images for them. */ + r = secure_getenv_bool("SYSTEMD_DISSECT_TPM2_UNLOCK"); + if (r == 0) + flags &= ~DISSECT_IMAGE_TPM2_UNLOCK; + #if HAVE_LIBCRYPTSETUP r = decrypted_image_new(&d); if (r < 0) diff --git a/src/shared/dissect-image.h b/src/shared/dissect-image.h index 7ba04ebfdd1..4cede743d5f 100644 --- a/src/shared/dissect-image.h +++ b/src/shared/dissect-image.h @@ -81,6 +81,7 @@ typedef enum DissectImageFlags { DISSECT_IMAGE_ALLOW_INTERACTIVE_AUTH = 1 << 27, /* Allow interactive authorization when going through mountfsd */ DISSECT_IMAGE_FOREIGN_UID = 1 << 28, /* Request a foreign UID range mapping */ DISSECT_IMAGE_IDENTITY_UID = 1 << 29, /* Explicitly request an identity UID range mapping */ + DISSECT_IMAGE_TPM2_UNLOCK = 1 << 30, /* If no passphrase is supplied, try unlocking LUKS partitions via a TPM2 token enrolled in the LUKS2 header */ } DissectImageFlags; typedef struct DissectedImage { diff --git a/src/sysext/sysext.c b/src/sysext/sysext.c index d751dd90c05..d4478d8c981 100644 --- a/src/sysext/sysext.c +++ b/src/sysext/sysext.c @@ -3249,6 +3249,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTSETUP_NOTE(recommended); LIBMOUNT_NOTE(required); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/sysinstall/sysinstall.c b/src/sysinstall/sysinstall.c index ce5775e9b99..b7f3e45e2ae 100644 --- a/src/sysinstall/sysinstall.c +++ b/src/sysinstall/sysinstall.c @@ -2109,6 +2109,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); + TPM2_NOTE(suggested); setlocale(LC_ALL, ""); diff --git a/src/systemctl/systemctl-main.c b/src/systemctl/systemctl-main.c index fea186d9cd2..03cc9b1830f 100644 --- a/src/systemctl/systemctl-main.c +++ b/src/systemctl/systemctl-main.c @@ -30,6 +30,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTSETUP_NOTE(suggested); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); setlocale(LC_ALL, ""); log_setup(); diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index 4d2f745c6f8..934b6452190 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -3552,9 +3552,7 @@ static int run(int argc, char *argv[]) { LIBFDISK_NOTE(recommended); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); - LIBTSS2_ESYS_NOTE(suggested); - LIBTSS2_MU_NOTE(suggested); - LIBTSS2_RC_NOTE(suggested); + TPM2_NOTE(suggested); log_setup(); diff --git a/src/sysusers/sysusers.c b/src/sysusers/sysusers.c index 3771185f538..b639e822ac9 100644 --- a/src/sysusers/sysusers.c +++ b/src/sysusers/sysusers.c @@ -2288,6 +2288,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); char **args = NULL; r = parse_argv(argc, argv, &args); diff --git a/src/tmpfiles/tmpfiles.c b/src/tmpfiles/tmpfiles.c index b1f81d127c7..8477222add2 100644 --- a/src/tmpfiles/tmpfiles.c +++ b/src/tmpfiles/tmpfiles.c @@ -5288,6 +5288,7 @@ static int run(int argc, char *argv[]) { LIBCRYPTO_NOTE(suggested); LIBMOUNT_NOTE(recommended); LIBSELINUX_NOTE(recommended); + TPM2_NOTE(suggested); char **args = NULL; r = parse_argv(argc, argv, &args); From 600fcc6dcbbbcb6b25de0d4738f857e8cc97c765 Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 22 Sep 2026 11:59:54 +0000 Subject: [PATCH 4/5] dissect,nspawn,sysext: enable TPM2 unlocking of DDIs, document it Set DISSECT_IMAGE_TPM2_UNLOCK where unlocking an image's LUKS partitions via the local TPM2 is wanted and the caller is trusted: * systemd-dissect, an admin tool operating on an image the invoking user can read anyway; * systemd-nspawn --image=, which already prompts for a passphrase interactivly, so TPM2 first and passphrase second is the natural order; * systemd-sysext/systemd-confext, which decrypt without any passphrase, so extension images sealed to the host can be activated without user interaction. For sysext/confext setting the flag at merge time is not enough: before merging, the extension-release metadata is read via image_read_metadata() in discover-image.c, which uses its own flag set and would already fail with -ENOKEY on an encrypted image. Give it an extra_flags parameter and pass the flag from there, too; the other callers (machined, portabled) pass 0 and keep their behaviour. Two units need adjusting for this to work when started by the service manager: systemd-nspawn@.service has DevicePolicy=closed, so allow /dev/tpmrm0 there, and the sysext/confext services are not ordered against the TPM2 device showing up, so order them after tpm2.target like the pcr* services. The initrd variants cannot be ordered the same way: they run before local-fs-pre.target, while the software TPM fallback only becomes available after the early ESP mount, which is ordered after local-fs-pre.target, so a static After=tpm2.target would be an ordering cycle with systemd.tpm2_software_fallback=1. Hence systemd-tpm2-generator adds the ordering as a drop-in instead, unless the fallback is in use. That drop-in also orders them after systemd-pcrphase-initrd.service: nothing else guarantees the "enter-initrd" phase has been measured into PCR 11 before the initrd extension units merge their images (the barrier's Before=systemd-sysext.service is a leftover from when that unit was also used in the initrd), so tokens bound to a signed PCR 11 policy or a pcrlock policy would race the measurement. Deliberately not enabled for now: RootImage=/ExtensionImages=/MountImages= in units, portabled and gpt-auto. For those an unconditional default is probably not wanted but a per-unit or per-image switch, which is left for a follow-up. systemd-mountfsd, which unprivileged systemd-dissect and systemd-nspawn delegate to, keeps its own flag set and does not honour the flag, hence TPM2 unlocking is only available when running privileged; the man pages say so. Document the behaviour and $SYSTEMD_DISSECT_TPM2_UNLOCK= in the dissect, nspawn and sysext man pages and in docs/ENVIRONMENT.md. --- docs/ENVIRONMENT.md | 8 +++++ man/systemd-dissect.xml | 9 +++++- man/systemd-nspawn.xml | 7 ++++- man/systemd-sysext.xml | 6 +++- man/systemd-tpm2-generator.xml | 10 +++++++ src/dissect/dissect.c | 3 +- src/machine/image-dbus.c | 8 ++--- src/machine/machined-varlink.c | 2 +- src/nspawn/nspawn.c | 1 + src/portable/portabled-image-bus.c | 2 +- src/shared/discover-image.c | 5 ++-- src/shared/discover-image.h | 2 +- src/shared/forward.h | 1 + src/sysext/sysext.c | 5 ++-- src/tpm2-setup/tpm2-generator.c | 42 ++++++++++++++++++++++++++- units/systemd-confext-initrd.service | 2 ++ units/systemd-confext-sysroot.service | 2 ++ units/systemd-confext.service | 2 ++ units/systemd-nspawn@.service.in | 4 +++ units/systemd-sysext-initrd.service | 2 ++ units/systemd-sysext-sysroot.service | 2 ++ units/systemd-sysext.service | 2 ++ 22 files changed, 111 insertions(+), 16 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index d9453b5d3ce..5f6ff5d7316 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -146,6 +146,14 @@ All tools: RootImage=/ExtensionImages=/MountImages= in unit files default to enabled, while other uses default to disabled for this. +* `$SYSTEMD_DISSECT_TPM2_UNLOCK=` — takes a boolean. If false, turns off + unlocking of LUKS encrypted partitions in disk images via a TPM2 token + enrolled in the LUKS2 header (e.g. via `systemd-cryptenroll --tpm2-device=…`) + for the tools that do this by default if no passphrase is supplied, i.e. + `systemd-dissect`, `systemd-nspawn` and `systemd-sysext`/`systemd-confext`. + It cannot turn this on for other uses (including `systemd-mountfsd`), + which act on behalf of unprivileged clients. + `systemctl`: * `$SYSTEMCTL_FORCE_BUS=1` — if set, do not connect to PID 1's private D-Bus diff --git a/man/systemd-dissect.xml b/man/systemd-dissect.xml index 670fb2a5741..b5fc5293c1b 100644 --- a/man/systemd-dissect.xml +++ b/man/systemd-dissect.xml @@ -152,7 +152,14 @@ When the OS image contains LUKS encrypted or Verity integrity protected file systems appropriate volumes are automatically set up and marked for automatic disassembly when the image is - unmounted. + unmounted. LUKS volumes carrying a TPM2 token (as enrolled via the + option of + systemd-cryptenroll1) + are unlocked via the local TPM2 automatically, before a passphrase is requested. This may be turned + off via the $SYSTEMD_DISSECT_TPM2_UNLOCK environment variable. Note that this + requires privileges: when invoked unprivileged, images are mounted via + systemd-mountfsd.service8, + which does not currently support unlocking via TPM2. The OS image may either be specified as path to an OS image stored in a regular file or may refer to block device node (in the latter case, the block device must be the "whole" device, i.e. not diff --git a/man/systemd-nspawn.xml b/man/systemd-nspawn.xml index 58d4c84b36c..44983b72108 100644 --- a/man/systemd-nspawn.xml +++ b/man/systemd-nspawn.xml @@ -341,7 +341,12 @@ /efi (or /boot as fallback) in case a directory by this name exists and is empty. - Partitions encrypted with LUKS are automatically decrypted. Also, on GPT images dm-verity data integrity + Partitions encrypted with LUKS are automatically decrypted, via a TPM2 token enrolled in the LUKS2 + header if one matches the local TPM2 (this may be turned off via the + $SYSTEMD_DISSECT_TPM2_UNLOCK environment variable), or otherwise by prompting for a + passphrase. Unlocking via TPM2 requires privileges: when invoked unprivileged, images are mounted via + systemd-mountfsd.service8, + which does not currently support it. Also, on GPT images dm-verity data integrity hash partitions are set up if the root hash for them is specified using the option. diff --git a/man/systemd-sysext.xml b/man/systemd-sysext.xml index e9d275fac49..984441add5b 100644 --- a/man/systemd-sysext.xml +++ b/man/systemd-sysext.xml @@ -101,7 +101,11 @@ systemd-nspawn1 supports via its / switches and those that the service manager supports via /. Similar to - them they may optionally carry Verity authentication information. + them they may optionally carry Verity authentication information. LUKS encrypted extension images + carrying a TPM2 token (as enrolled via the option of + systemd-cryptenroll1) + are unlocked via the local TPM2 automatically, without any passphrase. This may be turned off via the + $SYSTEMD_DISSECT_TPM2_UNLOCK environment variable. System extensions are searched for in the directories /etc/extensions/, /run/extensions/ and diff --git a/man/systemd-tpm2-generator.xml b/man/systemd-tpm2-generator.xml index b45cf29be86..57aee889b94 100644 --- a/man/systemd-tpm2-generator.xml +++ b/man/systemd-tpm2-generator.xml @@ -53,6 +53,16 @@ systemd-tpm2-swtpm.service8 service. + In the initrd, the generator also orders + systemd-sysext-initrd.service8 + and + systemd-confext-initrd.service8 + after tpm2.target and + systemd-pcrphase-initrd.service8, + so that LUKS encrypted extension images sealed to the TPM2 can be unlocked. This is not done if the + software TPM fallback is in use, as the software TPM only becomes available after the extension images + are merged, hence such images cannot be unlocked in the initrd in that case. + systemd-tpm2-generator implements systemd.generator7. diff --git a/src/dissect/dissect.c b/src/dissect/dissect.c index 7056b7b1d15..a1266eeebfb 100644 --- a/src/dissect/dissect.c +++ b/src/dissect/dissect.c @@ -92,7 +92,8 @@ static DissectImageFlags arg_flags = DISSECT_IMAGE_GROWFS | DISSECT_IMAGE_PIN_PARTITION_DEVICES | DISSECT_IMAGE_ADD_PARTITION_DEVICES | - DISSECT_IMAGE_ALLOW_USERSPACE_VERITY; + DISSECT_IMAGE_ALLOW_USERSPACE_VERITY | + DISSECT_IMAGE_TPM2_UNLOCK; static VeritySettings arg_verity_settings = VERITY_SETTINGS_DEFAULT; static sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF; static PagerFlags arg_pager_flags = 0; diff --git a/src/machine/image-dbus.c b/src/machine/image-dbus.c index 4a61b48f3f7..27b1cc15740 100644 --- a/src/machine/image-dbus.c +++ b/src/machine/image-dbus.c @@ -295,7 +295,7 @@ int bus_image_method_get_hostname( int r; if (!image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope, /* extra_flags= */ 0); if (r < 0) return sd_bus_error_set_errnof(error, r, "Failed to read image metadata: %m"); } @@ -314,7 +314,7 @@ int bus_image_method_get_machine_id( int r; if (!image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope, /* extra_flags= */ 0); if (r < 0) return sd_bus_error_set_errnof(error, r, "Failed to read image metadata: %m"); } @@ -343,7 +343,7 @@ int bus_image_method_get_machine_info( int r; if (!image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope, /* extra_flags= */ 0); if (r < 0) return sd_bus_error_set_errnof(error, r, "Failed to read image metadata: %m"); } @@ -361,7 +361,7 @@ int bus_image_method_get_os_release( int r; if (!image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope, /* extra_flags= */ 0); if (r < 0) return sd_bus_error_set_errnof(error, r, "Failed to read image metadata: %m"); } diff --git a/src/machine/machined-varlink.c b/src/machine/machined-varlink.c index ae121395c01..4b449ff2656 100644 --- a/src/machine/machined-varlink.c +++ b/src/machine/machined-varlink.c @@ -633,7 +633,7 @@ static int list_image_one_and_maybe_read_metadata(Manager *m, sd_varlink *link, assert(image); if (should_acquire_metadata(am) && !image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_container, m->runtime_scope, /* extra_flags= */ 0); if (r < 0 && am != ACQUIRE_METADATA_GRACEFUL) return log_debug_errno(r, "Failed to read image metadata: %m"); if (r < 0) diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c index 4709928384b..e2b02ece7f4 100644 --- a/src/nspawn/nspawn.c +++ b/src/nspawn/nspawn.c @@ -3877,6 +3877,7 @@ static DissectImageFlags determine_dissect_image_flags(void) { (arg_read_only ? DISSECT_IMAGE_READ_ONLY : DISSECT_IMAGE_FSCK|DISSECT_IMAGE_GROWFS) | DISSECT_IMAGE_ALLOW_USERSPACE_VERITY | DISSECT_IMAGE_VERITY_SHARE | + DISSECT_IMAGE_TPM2_UNLOCK | (arg_console_mode == CONSOLE_INTERACTIVE && arg_ask_password ? DISSECT_IMAGE_ALLOW_INTERACTIVE_AUTH : 0) | ((arg_userns_ownership == USER_NAMESPACE_OWNERSHIP_FOREIGN) ? DISSECT_IMAGE_FOREIGN_UID : (arg_userns_ownership != USER_NAMESPACE_OWNERSHIP_AUTO) ? DISSECT_IMAGE_IDENTITY_UID : 0); diff --git a/src/portable/portabled-image-bus.c b/src/portable/portabled-image-bus.c index 5a5393e986a..935f0e619bb 100644 --- a/src/portable/portabled-image-bus.c +++ b/src/portable/portabled-image-bus.c @@ -61,7 +61,7 @@ int bus_image_common_get_os_release( return 1; if (!image->metadata_valid) { - r = image_read_metadata(image, /* root= */ NULL, &image_policy_service, m->runtime_scope); + r = image_read_metadata(image, /* root= */ NULL, &image_policy_service, m->runtime_scope, /* extra_flags= */ 0); if (r < 0) return sd_bus_error_set_errnof(error, r, "Failed to read image metadata: %m"); } diff --git a/src/shared/discover-image.c b/src/shared/discover-image.c index fa0fd1928f6..7c2a7901a9e 100644 --- a/src/shared/discover-image.c +++ b/src/shared/discover-image.c @@ -2228,7 +2228,7 @@ int image_setup_pool(RuntimeScope scope, ImageClass class, bool use_btrfs_subvol return 0; } -int image_read_metadata(Image *i, const char *root, const ImagePolicy *image_policy, RuntimeScope scope) { +int image_read_metadata(Image *i, const char *root, const ImagePolicy *image_policy, RuntimeScope scope, DissectImageFlags extra_flags) { _cleanup_(release_lock_file) LockFile global_lock = LOCK_FILE_INIT, local_lock = LOCK_FILE_INIT; int r; @@ -2316,7 +2316,8 @@ int image_read_metadata(Image *i, const char *root, const ImagePolicy *image_pol DISSECT_IMAGE_PIN_PARTITION_DEVICES | DISSECT_IMAGE_VALIDATE_OS | DISSECT_IMAGE_VALIDATE_OS_EXT | - DISSECT_IMAGE_ALLOW_USERSPACE_VERITY; + DISSECT_IMAGE_ALLOW_USERSPACE_VERITY | + extra_flags; r = loop_device_make_by_path( i->path, diff --git a/src/shared/discover-image.h b/src/shared/discover-image.h index 71d00cbdb07..63fc9a65c7b 100644 --- a/src/shared/discover-image.h +++ b/src/shared/discover-image.h @@ -76,7 +76,7 @@ int image_get_pool_usage(RuntimeScope scope, ImageClass class, uint64_t *ret); int image_get_pool_limit(RuntimeScope scope, ImageClass class, uint64_t *ret); int image_setup_pool(RuntimeScope scope, ImageClass class, bool use_btrfs_subvol, bool use_btrfs_quota); -int image_read_metadata(Image *i, const char *root, const ImagePolicy *image_policy, RuntimeScope scope); +int image_read_metadata(Image *i, const char *root, const ImagePolicy *image_policy, RuntimeScope scope, DissectImageFlags extra_flags); bool image_in_search_path(RuntimeScope scope, ImageClass class, const char *root, const char *image); diff --git a/src/shared/forward.h b/src/shared/forward.h index f70a9a12753..d508f24ccda 100644 --- a/src/shared/forward.h +++ b/src/shared/forward.h @@ -20,6 +20,7 @@ typedef enum BusTransport BusTransport; typedef enum CatFlags CatFlags; typedef enum CertificateSourceType CertificateSourceType; typedef enum ConditionType ConditionType; +typedef enum DissectImageFlags DissectImageFlags; typedef enum DnsAnswerFlags DnsAnswerFlags; typedef enum DnsCacheMode DnsCacheMode; typedef enum DnsOverTlsMode DnsOverTlsMode; diff --git a/src/sysext/sysext.c b/src/sysext/sysext.c index d4478d8c981..28545481c7f 100644 --- a/src/sysext/sysext.c +++ b/src/sysext/sysext.c @@ -2068,7 +2068,8 @@ static int merge_subprocess( DISSECT_IMAGE_USR_NO_ROOT | DISSECT_IMAGE_ADD_PARTITION_DEVICES | DISSECT_IMAGE_PIN_PARTITION_DEVICES | - DISSECT_IMAGE_ALLOW_USERSPACE_VERITY; + DISSECT_IMAGE_ALLOW_USERSPACE_VERITY | + DISSECT_IMAGE_TPM2_UNLOCK; r = verity_settings_load(&verity_settings, img->path, NULL, NULL); if (r < 0) @@ -2651,7 +2652,7 @@ static int image_discover_and_read_metadata(const Context *c, Hashmap **ret_imag return log_error_errno(r, "Failed to discover images: %m"); HASHMAP_FOREACH(img, images) { - r = image_read_metadata(img, c->root, image_class_info[c->image_class].default_image_policy, RUNTIME_SCOPE_SYSTEM); + r = image_read_metadata(img, c->root, image_class_info[c->image_class].default_image_policy, RUNTIME_SCOPE_SYSTEM, DISSECT_IMAGE_TPM2_UNLOCK); if (r < 0) return log_error_errno(r, "Failed to read metadata for image %s: %m", img->name); } diff --git a/src/tpm2-setup/tpm2-generator.c b/src/tpm2-setup/tpm2-generator.c index 6c27fa106af..a9dc1462ab7 100644 --- a/src/tpm2-setup/tpm2-generator.c +++ b/src/tpm2-setup/tpm2-generator.c @@ -12,6 +12,7 @@ #include "path-util.h" #include "proc-cmdline.h" #include "special.h" +#include "strv.h" #include "tpm2-util.h" /* A small generator that enqueues tpm2.target as synchronization point if the TPM2 device hasn't shown up @@ -74,6 +75,39 @@ static int generate_tpm_target_symlink(Tpm2Support support, bool software_fallba return 0; } +static int generate_initrd_extension_ordering(bool software_fallback_enabled) { + int r; + + /* systemd-sysext-initrd.service and systemd-confext-initrd.service might have to unlock LUKS + * encrypted extension images via the TPM2, hence order them after tpm2.target and after the + * "enter-initrd" PCR measurement. This cannot be done statically in the units: they run before + * local-fs-pre.target, but the software TPM fallback only becomes available after the early ESP + * mount, which is ordered after local-fs-pre.target, so with the fallback the ordering would be + * a cycle. Hence add it here, and only if the fallback is not in use. */ + + if (!in_initrd()) + return 0; + + if (software_fallback_enabled) { + log_debug("Not ordering initrd extension image units after tpm2.target, as the software TPM fallback is in use."); + return 0; + } + + FOREACH_STRING(u, "systemd-sysext-initrd.service", "systemd-confext-initrd.service") { + r = write_drop_in( + arg_dest, + u, + 50, "tpm2", + "# Automatically generated by systemd-tpm2-generator\n\n" + "[Unit]\n" + "After=tpm2.target systemd-pcrphase-initrd.service\n"); + if (r < 0) + return log_error_errno(r, "Failed to order %s after tpm2.target: %m", u); + } + + return 0; +} + static int generate_swtpm_symlink(Tpm2Support support) { int r; @@ -158,7 +192,13 @@ static int generate_now(void) { if (r < 0) return r; - r = generate_tpm_target_symlink(support, /* software_fallback_enabled= */ r > 0); + bool software_fallback_enabled = r > 0; + + r = generate_tpm_target_symlink(support, software_fallback_enabled); + if (r < 0) + return r; + + r = generate_initrd_extension_ordering(software_fallback_enabled); if (r < 0) return r; diff --git a/units/systemd-confext-initrd.service b/units/systemd-confext-initrd.service index 9984bd70652..d3961e37a77 100644 --- a/units/systemd-confext-initrd.service +++ b/units/systemd-confext-initrd.service @@ -28,6 +28,8 @@ Conflicts=initrd-switch-root.target Before=initrd-switch-root.target Wants=modprobe@loop.service modprobe@dm_mod.service After=modprobe@loop.service modprobe@dm_mod.service +# Extension images might be LUKS encrypted and sealed to the local TPM2, but ordering after tpm2.target here +# would be a cycle with the software TPM fallback, hence systemd-tpm2-generator adds it where possible [Service] Type=oneshot diff --git a/units/systemd-confext-sysroot.service b/units/systemd-confext-sysroot.service index e30193e17e4..6fa5f602b2f 100644 --- a/units/systemd-confext-sysroot.service +++ b/units/systemd-confext-sysroot.service @@ -23,6 +23,8 @@ Conflicts=shutdown.target Before=initrd-root-fs.target shutdown.target Wants=modprobe@loop.service modprobe@dm_mod.service After=modprobe@loop.service modprobe@dm_mod.service sysroot.mount sysroot-usr.mount systemd-volatile-root.service +# Extension images might be LUKS encrypted and sealed to the local TPM2 +After=tpm2.target [Service] Type=oneshot diff --git a/units/systemd-confext.service b/units/systemd-confext.service index ffbf8345b8d..2e3f89f8fb7 100644 --- a/units/systemd-confext.service +++ b/units/systemd-confext.service @@ -21,6 +21,8 @@ ConditionKernelCommandLine=!systemd.confext=0 DefaultDependencies=no After=local-fs.target +# Extension images might be LUKS encrypted and sealed to the local TPM2 +After=tpm2.target Before=sysinit.target systemd-tmpfiles-setup.service Conflicts=shutdown.target Before=shutdown.target diff --git a/units/systemd-nspawn@.service.in b/units/systemd-nspawn@.service.in index d540a071a48..2b9984bf231 100644 --- a/units/systemd-nspawn@.service.in +++ b/units/systemd-nspawn@.service.in @@ -51,5 +51,9 @@ DeviceAllow=block-blkext rw DeviceAllow=/dev/mapper/control rw DeviceAllow=block-device-mapper rw +# nspawn unlocks LUKS encrypted images via a TPM2 token enrolled in them, in +# which case it needs access to the TPM2 resource manager device. +DeviceAllow=/dev/tpmrm0 rw + [Install] WantedBy=machines.target diff --git a/units/systemd-sysext-initrd.service b/units/systemd-sysext-initrd.service index de106abad3e..d40dfdf82dc 100644 --- a/units/systemd-sysext-initrd.service +++ b/units/systemd-sysext-initrd.service @@ -22,6 +22,8 @@ ConditionKernelCommandLine=!rd.systemd.sysext=0 DefaultDependencies=no After=systemd-confext-initrd.service +# Extension images might be LUKS encrypted and sealed to the local TPM2, but ordering after tpm2.target here +# would be a cycle with the software TPM fallback, hence systemd-tpm2-generator adds it where possible Before=local-fs-pre.target cryptsetup-pre.target systemd-tmpfiles-setup.service Wants=local-fs-pre.target cryptsetup-pre.target Conflicts=initrd-switch-root.target diff --git a/units/systemd-sysext-sysroot.service b/units/systemd-sysext-sysroot.service index b6a586925e2..787a51a9e70 100644 --- a/units/systemd-sysext-sysroot.service +++ b/units/systemd-sysext-sysroot.service @@ -23,6 +23,8 @@ Conflicts=shutdown.target Before=initrd-root-fs.target shutdown.target Wants=modprobe@loop.service modprobe@dm_mod.service After=modprobe@loop.service modprobe@dm_mod.service sysroot.mount sysroot-usr.mount systemd-volatile-root.service +# Extension images might be LUKS encrypted and sealed to the local TPM2 +After=tpm2.target [Service] Type=oneshot diff --git a/units/systemd-sysext.service b/units/systemd-sysext.service index 1ee16be6172..c0847c722d5 100644 --- a/units/systemd-sysext.service +++ b/units/systemd-sysext.service @@ -22,6 +22,8 @@ ConditionKernelCommandLine=!systemd.sysext=0 DefaultDependencies=no After=systemd-confext.service After=local-fs.target +# Extension images might be LUKS encrypted and sealed to the local TPM2 +After=tpm2.target Before=sysinit.target systemd-tmpfiles-setup.service Conflicts=shutdown.target Before=shutdown.target From 85636d4e7d4b7c395b9692c3dcfcde2f628820cf Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 22 Sep 2026 12:36:11 +0000 Subject: [PATCH 5/5] test: add TEST-70-TPM2 subtest for TPM2 unlocking of DDIs Build a DDI with systemd-repart, enroll the TPM2 into its LUKS partition with systemd-cryptenroll bound to PCR 16, and check that systemd-dissect --mount unlocks it without a passphrase, that a passphrase supplied via $PASSWORD is used exclusively without touching the TPM2, that $SYSTEMD_DISSECT_TPM2_UNLOCK=0 makes it ask for a passphrase without touching the TPM2, that extending PCR 16 invalidates the token and the passphrase fallback kicks in, and that a token written by systemd-repart's Encrypt=tpm2 is consumable as well. Also merge a system extension image enrolled the same way with systemd-sysext into a fake root: sysext never asks for a passphrase, so there the TPM2 token is the only way in, which makes it the strongest demonstration of the feature. PCR 16 is used since nothing else in TEST-70 depends on it, and it is reset on exit. Where systemd-dissect has to ask for a passphrase, the query is satisfied via the "dissect.passphrase" credential, as it would otherwise block on the password agent forever; for the same reason all mounts are wrapped in timeout(1) and there is no "no passphrase at all + non-matching token" case. The dissect log is only printed when a mount fails, to not spam the journal. --- test/units/TEST-70-TPM2.dissect.sh | 188 +++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100755 test/units/TEST-70-TPM2.dissect.sh diff --git a/test/units/TEST-70-TPM2.dissect.sh b/test/units/TEST-70-TPM2.dissect.sh new file mode 100755 index 00000000000..9b31b295a74 --- /dev/null +++ b/test/units/TEST-70-TPM2.dissect.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +# Test that systemd-dissect and systemd-sysext unlock a DDI whose LUKS partition carries a "systemd-tpm2" +# LUKS2 token via the local TPM2 if no passphrase is supplied, that a supplied passphrase is used exclusively, +# and that systemd-dissect falls back to asking for a passphrase if the token does not match the current +# system state (DISSECT_IMAGE_TPM2_UNLOCK in dissect-image.c). +# +# The images' LUKS partitions are bound to PCR 16 (the resettable "debug" PCR nothing else in this test +# depends on), so that extending that PCR afterwards invalidates the tokens. The PCR is reset on exit. +# +# Without a matching TPM2 token and without $PASSWORD, systemd-dissect asks for a passphrase via +# ask_password_auto() with an infinite timeout. That query is satisfied here via the "dissect.passphrase" +# credential, so that the fallback can be exercised without blocking on the password agent forever in this +# non-interactive environment. For the same reason there is no "no passphrase at all + non-matching token +# must fail" case. + +if ! tpm_has_pcr sha256 16; then + echo "TPM2 has no sha256 PCR 16, skipping" + exit 77 +fi + +export SYSTEMD_LOG_LEVEL=debug + +WORKDIR="$(mktemp -d /tmp/dissect-tpm2.XXXXXXXXXX)" +IMAGE="$WORKDIR/image.raw" +IMAGE_REPART="$WORKDIR/image-repart.raw" +MNT="$WORKDIR/mnt" +LOG="$WORKDIR/dissect.log" +# Fake root for systemd-sysext --root=, so that the real /usr is left alone +SYSEXT_ROOT="$WORKDIR/root" +SYSEXT_NAME="dissect-tpm2" +DEVICE="" + +at_exit() { + set +e + + if mountpoint -q "$MNT"; then + systemd-dissect --umount "$MNT" + fi + if [[ -n "$DEVICE" ]]; then + systemd-dissect --detach "$DEVICE" + fi + if [[ -d "$SYSEXT_ROOT" ]]; then + systemd-sysext --root="$SYSEXT_ROOT" unmerge + fi + + rm -rf "$WORKDIR" + + # Undo the PCR extension below, for the subtests that run after this one + tpm2_pcrreset 16 +} + +trap at_exit EXIT + +# All mounts are wrapped in timeout(1): should TPM2 unlocking not work, systemd-dissect would otherwise +# block on the password agent (see above) instead of failing the test. +dissect_mount() { + if ! timeout --kill-after=10 120 systemd-dissect --mount "$IMAGE" "$MNT" &>"$LOG"; then + cat "$LOG" + return 1 + fi + test -f "$MNT/usr/lib/os-release" + systemd-dissect --umount "$MNT" +} + +# Enroll the TPM2 into the LUKS partition of the specified image, bound to PCR 16 +enroll_tpm2() { + local image="${1:?}" + + DEVICE="$(systemd-dissect --attach "$image")" + udevadm wait --settle --timeout=10 "$DEVICE"p1 + # Use --tpm2-public-key= and --tpm2-pcrlock= to suppress auto-loading any PCR public key or pcrlock + # policy from the host + systemd-cryptenroll --unlock-key-file="$WORKDIR/passphrase" --tpm2-device=auto --tpm2-public-key= --tpm2-pcrlock= --tpm2-pcrs=16 "$DEVICE"p1 + systemd-dissect --detach "$DEVICE" + DEVICE="" +} + +mkdir -p "$WORKDIR/defs" "$WORKDIR/creds" "$MNT" +echo -n passphrase >"$WORKDIR/passphrase" +# Avoid "has 0644 mode that is too permissive" messages +chmod 0600 "$WORKDIR/passphrase" +# The same passphrase as a credential, for the cases where systemd-dissect has to ask for it +cp "$WORKDIR/passphrase" "$WORKDIR/creds/dissect.passphrase" + +# Build a DDI with a passphrase-encrypted root partition, then enroll the TPM2 into it with +# systemd-cryptenroll, i.e. the way such an image would be prepared by hand today. +cat >"$WORKDIR/defs/root.conf" <"$WORKDIR/ext/usr/lib/extension-release.d/extension-release.$SYSEXT_NAME" +touch "$WORKDIR/ext/usr/lib/$SYSEXT_NAME-marker" +echo "ID=testtest" >"$SYSEXT_ROOT/usr/lib/os-release" + +cat >"$WORKDIR/defs/root.conf" <"$WORKDIR/defs/root.conf" <