From 600fcc6dcbbbcb6b25de0d4738f857e8cc97c765 Mon Sep 17 00:00:00 2001 From: Adrian Reber Date: Tue, 22 Sep 2026 11:59:54 +0000 Subject: [PATCH] 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