From a78afc16168bdded6c8376d6bde9121719b24537 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Fri, 26 Jun 2026 11:57:29 +0200 Subject: [PATCH 1/4] vmspawn: add Intel TDX confidential VM support Wire up --coco=tdx alongside the existing SEV-SNP path. TDX requires KVM on x86_64, a raw TDVF firmware loaded via -bios (no pflash/NVRAM split), kernel-irqchip=split, and the "host" CPU model since QEMU rejects named models. Sets up the tdx-guest object and confidential-guest-support=tdx0. TDX measurement is different from QEMU's kernel-hashes injection: TDX provides runtime measurements via RTMRs, so the initial measurement only covers the firmware, which then measures the rest of the boot chain into those RTMRs (done by OVMF today). Therefore a restriction to direct kernel boot isn't required either. Signed-off-by: Paul Meyer --- man/systemd-vmspawn.xml | 17 +++++++++- src/vmspawn/vmspawn-settings.c | 1 + src/vmspawn/vmspawn-settings.h | 1 + src/vmspawn/vmspawn.c | 59 ++++++++++++++++++++++++++++------ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/man/systemd-vmspawn.xml b/man/systemd-vmspawn.xml index fd73e00770..66c49352a9 100644 --- a/man/systemd-vmspawn.xml +++ b/man/systemd-vmspawn.xml @@ -346,7 +346,8 @@ its current form) in a future version of systemd. Configures whether to run the guest as a confidential VM. Takes one of - no or sev-snp. Defaults to no. + no, sev-snp or tdx. Defaults to + no. sev-snp enables AMD SEV-SNP. This requires KVM on an x86_64 host with SNP-capable hardware and firmware. must point to a raw SNP-built @@ -367,6 +368,20 @@ recent version of systemd (supporting /.extra/system_credentials/). A vTPM, if attached via , must be treated as untrusted by the guest. + tdx enables Intel TDX. This requires KVM on an x86_64 host with + TDX-capable hardware and a TDX-enabled host kernel. As with sev-snp, + must point to a raw TDX-built OVMF (TDVF) .fd + image, which is loaded via QEMU's (pflash + NVRAM split is not + supported), and the CPU model is fixed to host. Firmware is measured into + MRTD when the TD is built. Secure Boot cannot be enrolled at runtime (there is no writable + NVRAM); its state is fixed by the supplied TDVF image and is part of the measured firmware. + When booting a UKI, the whole UKI PE is measured into RTMR 1, and the loaded sections are + measured individually by systemd-stub into RTMR 2. For direct linux boot, + firmware measures the kernel PE into RTMR 1, and the Linux EFI stub measures initrd and + command line into RTMR 2. A vTPM, if attached via , must be treated + as untrusted by the guest. + + diff --git a/src/vmspawn/vmspawn-settings.c b/src/vmspawn/vmspawn-settings.c index d05c4e1a9e..043920c36c 100644 --- a/src/vmspawn/vmspawn-settings.c +++ b/src/vmspawn/vmspawn-settings.c @@ -40,6 +40,7 @@ DEFINE_STRING_TABLE_LOOKUP(firmware, Firmware); static const char *const confidential_computing_table[_COCO_MAX] = { [COCO_NO] = "no", [COCO_AMD_SEV_SNP] = "sev-snp", + [COCO_INTEL_TDX] = "tdx", }; DEFINE_STRING_TABLE_LOOKUP(confidential_computing, ConfidentialComputing); diff --git a/src/vmspawn/vmspawn-settings.h b/src/vmspawn/vmspawn-settings.h index 9be3afdef4..5f488337f9 100644 --- a/src/vmspawn/vmspawn-settings.h +++ b/src/vmspawn/vmspawn-settings.h @@ -45,6 +45,7 @@ typedef enum Firmware { typedef enum ConfidentialComputing { COCO_NO, COCO_AMD_SEV_SNP, + COCO_INTEL_TDX, _COCO_MAX, _COCO_INVALID = -EINVAL, } ConfidentialComputing; diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 6e2329f7f9..41cf611aa9 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -634,7 +634,7 @@ static int parse_argv(int argc, char *argv[]) { break; } - OPTION_LONG("coco", "no|sev-snp", "Run the guest as a confidential VM"): { + OPTION_LONG("coco", "no|sev-snp|tdx", "Run the guest as a confidential VM"): { ConfidentialComputing cc = confidential_computing_from_string(opts.arg); if (cc < 0) return log_error_errno(cc, "Unknown --coco= value: %s", opts.arg); @@ -2607,11 +2607,11 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { use_kvm = r; } - if (arg_confidential_computing == COCO_AMD_SEV_SNP && !use_kvm) + if (arg_confidential_computing != COCO_NO && !use_kvm) return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), - "--coco=sev-snp requires KVM, but KVM is not available."); + "--coco= requires KVM, but KVM is not available."); - if (arg_firmware_type == FIRMWARE_UEFI && arg_confidential_computing != COCO_AMD_SEV_SNP) { + if (arg_firmware_type == FIRMWARE_UEFI && arg_confidential_computing == COCO_NO) { if (arg_firmware) r = load_ovmf_config(arg_firmware, &ovmf_config); else @@ -2685,7 +2685,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (r < 0) return r; - if (arg_confidential_computing == COCO_AMD_SEV_SNP) { + if (arg_confidential_computing != COCO_NO) { r = qemu_config_key(config_file, "kernel-irqchip", "split"); if (r < 0) return r; @@ -2719,6 +2719,10 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { r = qemu_config_key(config_file, "confidential-guest-support", "snp0"); if (r < 0) return r; + } else if (arg_confidential_computing == COCO_INTEL_TDX) { + r = qemu_config_key(config_file, "confidential-guest-support", "tdx0"); + if (r < 0) + return r; } r = qemu_config_section(config_file, "smp-opts", /* id= */ NULL, @@ -2927,6 +2931,11 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { "kernel-hashes", "on"); if (r < 0) return r; + } else if (arg_confidential_computing == COCO_INTEL_TDX) { + r = qemu_config_section(config_file, "object", "tdx0", + "qom-type", "tdx-guest"); + if (r < 0) + return r; } unsigned child_cid = arg_vsock_cid; @@ -2948,11 +2957,13 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { /* -cpu stays on cmdline since not all flags are supported in config. SNP needs a stable, * named CPU model so the launch measurement is reproducible across hosts; EPYC-v4 is the - * baseline that covers all SNP-capable processors (Milan and later). */ + * baseline that covers all SNP-capable processors (Milan and later). TDX requires host + * CPU model. */ const char *cpu_model = #ifdef __x86_64__ - arg_confidential_computing == COCO_AMD_SEV_SNP ? "EPYC-v4" - : "max,hv_relaxed,hv-vapic,hv-time"; + arg_confidential_computing == COCO_AMD_SEV_SNP ? "EPYC-v4" : + arg_confidential_computing == COCO_INTEL_TDX ? "host" : + "max,hv_relaxed,hv-vapic,hv-time"; #else "max"; #endif @@ -4128,7 +4139,7 @@ static int verify_arguments(void) { "--coco=sev-snp requires KVM, remove --kvm=no."); if (arg_firmware_type != FIRMWARE_UEFI) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), - "--coco can't be used with %s firmware", + "--coco=sev-snp can't be used with %s firmware", firmware_to_string(arg_firmware_type)); /* SNP can't use pflash + NVRAM split, so the firmware-descriptor * machinery doesn't apply. Require an explicit raw .fd path and @@ -4140,7 +4151,7 @@ static int verify_arguments(void) { log_debug("Using raw SNP firmware at %s (no NVRAM, no Secure Boot).", arg_firmware); if (set_contains(arg_firmware_features_include, "secure-boot")) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), - "--secure-boot=yes cannot be combined with --coco."); + "--coco=sev-snp cannot be combined with --secure-boot=yes."); if (arg_tpm > 0) log_warning("TPM can't be trusted by the confidential computing guest"); /* kernel-hashes=on only covers what QEMU itself loads via -kernel/-initrd/-append. @@ -4153,6 +4164,34 @@ static int verify_arguments(void) { "so kernel, initrd and cmdline are covered by the launch measurement."); } + if (arg_confidential_computing == COCO_INTEL_TDX) { + if (native_architecture() != ARCHITECTURE_X86_64) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "--coco=tdx is only supported on x86_64."); + if (arg_kvm == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "--coco=tdx requires KVM, remove --kvm=no."); + if (arg_firmware_type != FIRMWARE_UEFI) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "--coco=tdx can't be used with %s firmware", + firmware_to_string(arg_firmware_type)); + /* TDX can't use pflash + NVRAM split, so the firmware-descriptor + * machinery doesn't apply. Require an explicit raw .fd path and + * use it verbatim with -bios later. */ + if (!arg_firmware) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "--coco=tdx requires --firmware=PATH " + "pointing at a raw TDX-built OVMF (TDVF) .fd binary."); + log_debug("Using raw TDX firmware at %s (no NVRAM, no Secure Boot).", arg_firmware); + /* Secure Boot state is baked into the supplied TDVF image and can't be enrolled at + * runtime (no writable NVRAM), so --secure-boot=yes would silently have no effect. */ + if (set_contains(arg_firmware_features_include, "secure-boot")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "--coco=tdx cannot be combined with --secure-boot=yes."); + if (arg_tpm > 0) + log_warning("TPM can't be trusted by the confidential computing guest"); + } + return 0; } From 0a9f7b5487a60a9856cd92c0c069047ef6610276 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Mon, 29 Jun 2026 12:21:02 +0200 Subject: [PATCH 2/4] vmspawn: allow TDX guest to connect to host QGS To query a TD quote, the TDX guest must connect to the Quote Generation Service (QGS), a SGX enclave running on the host. We check if the service is exposed via a well-known unix socket, then pass that socket or a fallback well-known vsock address to QEMU. Signed-off-by: Paul Meyer --- man/systemd-vmspawn.xml | 6 +++++- src/vmspawn/vmspawn.c | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/man/systemd-vmspawn.xml b/man/systemd-vmspawn.xml index 66c49352a9..64cfab8294 100644 --- a/man/systemd-vmspawn.xml +++ b/man/systemd-vmspawn.xml @@ -379,7 +379,11 @@ measured individually by systemd-stub into RTMR 2. For direct linux boot, firmware measures the kernel PE into RTMR 1, and the Linux EFI stub measures initrd and command line into RTMR 2. A vTPM, if attached via , must be treated - as untrusted by the guest. + as untrusted by the guest. To obtain TD Quotes for remote attestation, the guest is wired + to the host's local TDX Quote Generation Service automatically: the unix socket + up /run/tdx-qgs/qgs.socket is used if it exists, otherwise vsock port 4050 + on the host (cid 2). If the QGS is listening on neither channel, the guest's quote requests will + fail. diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 41cf611aa9..2c35fef032 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -105,6 +105,13 @@ #define DISK_SERIAL_MAX_LEN_NVME 20 #define DISK_SERIAL_MAX_LEN_VIRTIO_BLK 20 +/* Well-known endpoints for the host's TDX Quote Generation Service (qgsd), auto-discovered so the + * guest can obtain TD Quotes. The Intel reference qgsd listens on a unix socket; the common + * deployment listens on vsock port 4050 (cid 2 = host). */ +#define TDX_QGS_UNIX_SOCKET_PATH "/run/tdx-qgs/qgs.socket" +#define TDX_QGS_VSOCK_CID "2" +#define TDX_QGS_VSOCK_PORT "4050" + /* First and one-past-last pcie.0 device-numbers used for multifunction-packed * pcie-root-ports. Sits above the auto-assigned virtio devices (0x01-0x03) and * below 0x1f, which q35 reserves for ICH9 LPC at 0x1f.0 (single-function). */ @@ -2936,6 +2943,36 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { "qom-type", "tdx-guest"); if (r < 0) return r; + + /* The guest needs a connection to the TDX Quote Generation Service (QGS) running on + * the host to obtain TD Quotes (e.g. via the systemd-report-sign-tsm). There are two + * well-known interfaces for QGS: a well-known unix socket, or vsock port 4050. QEMU + * only connects on a GetQuote request, and pointing at an absent QGS is harmless, + * the request just fails and the guest gets no quote. */ + r = is_socket(TDX_QGS_UNIX_SOCKET_PATH); + if (r < 0) + log_debug_errno(r, "Failed to check for QGS socket '%s', falling back to vsock: %m", TDX_QGS_UNIX_SOCKET_PATH); + if (r > 0) { + r = qemu_config_key(config_file, "quote-generation-socket.type", "unix"); + if (r < 0) + return r; + r = qemu_config_key(config_file, "quote-generation-socket.path", TDX_QGS_UNIX_SOCKET_PATH); + if (r < 0) + return r; + log_debug("Using TDX Quote Generation Service at unix socket %s.", TDX_QGS_UNIX_SOCKET_PATH); + } else { + r = qemu_config_key(config_file, "quote-generation-socket.type", "vsock"); + if (r < 0) + return r; + r = qemu_config_key(config_file, "quote-generation-socket.cid", TDX_QGS_VSOCK_CID); + if (r < 0) + return r; + r = qemu_config_key(config_file, "quote-generation-socket.port", TDX_QGS_VSOCK_PORT); + if (r < 0) + return r; + log_debug("No QGS unix socket found, pointing TDX quote generation at vsock %s:%s.", + TDX_QGS_VSOCK_CID, TDX_QGS_VSOCK_PORT); + } } unsigned child_cid = arg_vsock_cid; From d17873f98fc4d992b7fd0f86e56999d27860d27d Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Mon, 29 Jun 2026 12:59:45 +0200 Subject: [PATCH 3/4] tsm-report: error on empty report Previously we would return a valid signature containing the empty outblob, which is undesirable. In other cases where we cannot query a report because the guest doesn't support it we currently return an empty response so the signature aggregator in systemd-report silently skips it. In this case, we have everything we need to actually get a report on the guest side, but the host isn't providing us with the quote, so we fail. Signed-off-by: Paul Meyer --- src/shared/tsm-report.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/shared/tsm-report.c b/src/shared/tsm-report.c index d10eb7fdbf..ae57201f6c 100644 --- a/src/shared/tsm-report.c +++ b/src/shared/tsm-report.c @@ -142,6 +142,14 @@ static int tsm_report_fill( if (r < 0) return log_debug_errno(r, "Failed to read 'provider' attribute: %m"); + /* A zero-length outblob is never a valid attestation report, e.g. under Intel TDX the kernel + * yields an empty quote when the GetQuote round-trip to the host's Quote Generation Service + * produced nothing. */ + if (!iovec_is_set(&report->outblob)) + return log_debug_errno(SYNTHETIC_ERRNO(ENODATA), + "TSM provider '%s' returned an empty 'outblob'; no attestation report available.", + report->provider); + r = read_full_file_at(entry_fd, "auxblob", (char**) &report->auxblob.iov_base, &report->auxblob.iov_len); if (r < 0 && r != -ENOENT) /* auxblob is optional */ From 4dd73ad924a660b90bfa1d5d95e04566484e76e6 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Tue, 30 Jun 2026 13:30:08 +0200 Subject: [PATCH 4/4] core: trust SMBIOS credentials under Intel TDX SMBIOS OEM strings are host-controlled and normally distrusted by PID1 in confidential VMs. Under TDX, however, the SMBIOS table (including Type 11) is measured into RTMR0 by the firmware (TDVF), so a remote verifier can detect host tampering with credentials delivered this way. Accept them there, while keeping fw_cfg distrusted as those items are not measured even on TDX. This lets systemd-vmspawn deliver credentials to TDX guests via the normal SMBIOS path, unlike SNP which requires the initrd cpio channel. Signed-off-by: Paul Meyer --- man/systemd-vmspawn.xml | 15 +++++++++------ src/core/import-creds.c | 9 ++++++++- src/vmspawn/vmspawn.c | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/man/systemd-vmspawn.xml b/man/systemd-vmspawn.xml index 64cfab8294..d8afb9d0e1 100644 --- a/man/systemd-vmspawn.xml +++ b/man/systemd-vmspawn.xml @@ -378,12 +378,15 @@ When booting a UKI, the whole UKI PE is measured into RTMR 1, and the loaded sections are measured individually by systemd-stub into RTMR 2. For direct linux boot, firmware measures the kernel PE into RTMR 1, and the Linux EFI stub measures initrd and - command line into RTMR 2. A vTPM, if attached via , must be treated - as untrusted by the guest. To obtain TD Quotes for remote attestation, the guest is wired - to the host's local TDX Quote Generation Service automatically: the unix socket - up /run/tdx-qgs/qgs.socket is used if it exists, otherwise vsock port 4050 - on the host (cid 2). If the QGS is listening on neither channel, the guest's quote requests will - fail. + command line into RTMR 2. Credentials passed via or + are delivered through SMBIOS Type 11 OEM strings, which the + firmware (TDVF) measures into RTMR 0. This channel is measured but not confidential with respect + to the host or VMM, since the host assembles the SMBIOS table. A vTPM, if attached via + , must be treated as untrusted by the guest. To obtain TD Quotes for + remote attestation, the guest is wired to the host's local TDX Quote Generation Service + automatically: the unix socket up /run/tdx-qgs/qgs.socket is used if it + exists, otherwise vsock port 4050 on the host (cid 2). If the QGS is listening on neither + channel, the guest's quote requests will fail. diff --git a/src/core/import-creds.c b/src/core/import-creds.c index 98675d2fc5..b8db23b85a 100644 --- a/src/core/import-creds.c +++ b/src/core/import-creds.c @@ -622,7 +622,14 @@ static int import_credentials_smbios(ImportCredentialsContext *c) { if (detect_container() > 0) /* don't access /sys/ in a container */ return 0; - if (detect_confidential_virtualization() > 0) /* don't trust firmware if confidential VMs */ + /* SMBIOS OEM strings are host-controlled, so by default we don't trust them in a confidential VM. + * Exception: under Intel TDX the SMBIOS table (including Type 11) is measured into RTMR0 by the + * firmware (TDVF's SmbiosMeasurementDxe) on direct boot, and additionally by systemd-stub on UKI + * boot, so a remote verifier can detect host tampering with these credentials. As with the kernel + * command line (also host-influenced but measured), we can't and needn't locally verify the + * measurement actually happened; the verifier anchors the firmware's behaviour via MRTD. */ + ConfidentialVirtualization cv = detect_confidential_virtualization(); + if (cv > 0 && cv != CONFIDENTIAL_VIRTUALIZATION_TDX) return 0; for (unsigned i = 0;; i++) { diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 2c35fef032..00df10874c 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -3579,7 +3579,7 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { * confidential VMs. Instead, package credentials into a cpio archive appended to the initrd * (mirroring what systemd-stub does for ESP credentials) so they enter the launch measurement * via QEMU's "kernel-hashes=on". The new initrd path requires a guest PID1 that knows about - * /.extra/system_credentials/, so we keep this scoped to SNP for now. Non-CoCo guests + * /.extra/system_credentials/, so we keep this scoped to SNP for now. Non-SNP guests * continue to use the SMBIOS path below, which works with older systemd versions too. * Must run after all credential-mutating calls above so the cpio captures the complete set. */ bool use_initrd_cpio = arg_confidential_computing == COCO_AMD_SEV_SNP &&