From 9e7b8b734f16e49d9bbadd5f99452293d0cd9b48 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Wed, 3 Jun 2026 13:23:27 +0200 Subject: [PATCH 1/4] core: import trusted initrd credentials PID1's import_credentials_boot() so far always treated initrd-delivered credentials as untrusted: anything found under /.extra/credentials/ was routed into ENCRYPTED_CREDENTIALS_DIRECTORY, forcing consumers to use LoadCredentialEncrypted= and provide credentials in systemd-creds encrypted form. That matches the trust model for stub which sources credentials from the EFI System Partition (mountable and editable offline). Host-side producers that take responsibility for the trust of the cpio they hand to the kernel have a different model: e.g. systemd-vmspawn builds an initrd-credentials cpio whose bytes are covered by the SEV-SNP launch measurement via QEMU's kernel-hashes=on (or, in non-confidential setups, by the host itself being the trust root). Forcing those through the @encrypted bucket would require null-key wrapping on the host and LoadCredentialEncrypted= on the consumer side, a needless API split for unit files that should otherwise be portable between confidential and non-confidential boots. Extend import_credentials_boot() to import credentials from /.extra/system_credentials/, routed into SYSTEM_CREDENTIALS_DIRECTORY. Consumers access these via LoadCredential= directly, with no encrypted-credential ceremony. The per-directory walk is hoisted into a small static helper so the new target bucket can share the existing copy/validation logic; behavior for the existing untrusted paths is unchanged. Also set the new RECURSE_DIR_MUST_BE_REGULAR flag as a drive-by. Signed-off-by: Paul Meyer --- src/core/import-creds.c | 271 +++++++++++++++++++++++----------------- 1 file changed, 158 insertions(+), 113 deletions(-) diff --git a/src/core/import-creds.c b/src/core/import-creds.c index 488d63eb41..98675d2fc5 100644 --- a/src/core/import-creds.c +++ b/src/core/import-creds.c @@ -168,18 +168,121 @@ static int finalize_credentials_dir(const char *dir, const char *envvar) { return 0; } -static int import_credentials_boot(void) { - _cleanup_(import_credentials_context_done) ImportCredentialsContext context = { - .target_dir_fd = -EBADF, - }; +static int import_credentials_from_initrd_path( + ImportCredentialsContext *c, + const char *source_path, + const char *target_dir, + bool with_mount) { + + _cleanup_free_ DirectoryEntries *de = NULL; + _cleanup_close_ int source_dir_fd = -EBADF; int r; - /* systemd-stub will wrap sidecar *.cred files from the UEFI kernel image directory into initrd - * cpios, so that they unpack into /.extra/. We'll pick them up from there and copy them into /run/ - * so that we can access them during the entire runtime (note that the initrd file system is erased - * during the initrd → host transition). Note that these credentials originate from an untrusted - * source (i.e. the ESP typically) and thus need to be authenticated later. We thus put them in a - * directory separate from the usual credentials which are from a trusted source. */ + source_dir_fd = open(source_path, O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); + if (source_dir_fd < 0) { + if (errno == ENOENT) { + log_debug("No credentials passed via %s.", source_path); + return 0; + } + + log_warning_errno(errno, "Failed to open '%s', ignoring: %m", source_path); + return 0; + } + + r = readdir_all(source_dir_fd, RECURSE_DIR_SORT|RECURSE_DIR_IGNORE_DOT|RECURSE_DIR_MUST_BE_REGULAR, &de); + if (r < 0) { + log_warning_errno(r, "Failed to read '%s' contents, ignoring: %m", source_path); + return 0; + } + + FOREACH_ARRAY(i, de->entries, de->n_entries) { + const struct dirent *d = *i; + _cleanup_close_ int cfd = -EBADF, nfd = -EBADF; + _cleanup_free_ char *n = NULL; + const char *e; + struct stat st; + + e = endswith(d->d_name, ".cred"); + if (!e) + continue; + + /* drop .cred suffix (which we want in the ESP sidecar dir, but not for our internal + * processing) */ + n = strndup(d->d_name, e - d->d_name); + if (!n) + return log_oom(); + + if (!credential_name_valid(n)) { + log_warning("Credential '%s' has invalid name, ignoring.", d->d_name); + continue; + } + + cfd = openat(source_dir_fd, d->d_name, O_RDONLY|O_CLOEXEC); + if (cfd < 0) { + log_warning_errno(errno, "Failed to open %s, ignoring: %m", d->d_name); + continue; + } + + if (fstat(cfd, &st) < 0) { + log_warning_errno(errno, "Failed to stat %s, ignoring: %m", d->d_name); + continue; + } + + r = stat_verify_regular(&st); + if (r < 0) { + log_warning_errno(r, "Credential file %s is not a regular file, ignoring: %m", d->d_name); + continue; + } + + if (!credential_size_ok(c, n, st.st_size)) + continue; + + r = acquire_credential_directory(c, target_dir, with_mount); + if (r < 0) + return r; + + nfd = open_credential_file_for_write(c->target_dir_fd, target_dir, n); + if (nfd == -EEXIST) + continue; + if (nfd < 0) + return nfd; + + r = copy_bytes(cfd, nfd, st.st_size, 0); + if (r < 0) { + (void) unlinkat(c->target_dir_fd, n, 0); + return log_error_errno(r, "Failed to create credential '%s': %m", n); + } + + c->size_sum += st.st_size; + c->n_credentials++; + + log_debug("Successfully copied boot credential '%s'.", n); + } + + return 0; +} + +static int import_credentials_boot(ImportCredentialsContext *system_ctx) { + _cleanup_(import_credentials_context_done) ImportCredentialsContext encrypted_ctx = { + .target_dir_fd = -EBADF, + }; + unsigned n_system_before; + int r; + + assert(system_ctx); + + /* The initrd may contain two flavours of credentials placed under /.extra/, copied + * across the initrd → host transition before the initrd tmpfs is erased: + * + * - /.extra/credentials/ and /.extra/global_credentials/ — placed by systemd-stub + * from the EFI System Partition. Trust model: untrusted, because the ESP can + * be mounted and edited offline. They are routed into the @encrypted bucket + * where consumers must authenticate them before use (via LoadCredentialEncrypted=). + * + * - /.extra/system_credentials/ — placed by host-side producers that take responsibility + * for the trust (e.g. systemd-vmspawn when its cpio is covered by the SEV-SNP launch + * measurement or when the host is the trust root in non-confidential setups). Routed + * into the @system bucket where consumers can use them directly via LoadCredential=. */ if (!in_initrd()) return 0; @@ -187,100 +290,36 @@ static int import_credentials_boot(void) { FOREACH_STRING(p, "/.extra/credentials/", /* specific to this boot menu */ "/.extra/global_credentials/") { /* boot partition wide */ - - _cleanup_free_ DirectoryEntries *de = NULL; - _cleanup_close_ int source_dir_fd = -EBADF; - - source_dir_fd = open(p, O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); - if (source_dir_fd < 0) { - if (errno == ENOENT) { - log_debug("No credentials passed via %s.", p); - continue; - } - - log_warning_errno(errno, "Failed to open '%s', ignoring: %m", p); - continue; - } - - r = readdir_all(source_dir_fd, RECURSE_DIR_SORT|RECURSE_DIR_IGNORE_DOT, &de); - if (r < 0) { - log_warning_errno(r, "Failed to read '%s' contents, ignoring: %m", p); - continue; - } - - FOREACH_ARRAY(i, de->entries, de->n_entries) { - const struct dirent *d = *i; - _cleanup_close_ int cfd = -EBADF, nfd = -EBADF; - _cleanup_free_ char *n = NULL; - const char *e; - struct stat st; - - e = endswith(d->d_name, ".cred"); - if (!e) - continue; - - /* drop .cred suffix (which we want in the ESP sidecar dir, but not for our internal - * processing) */ - n = strndup(d->d_name, e - d->d_name); - if (!n) - return log_oom(); - - if (!credential_name_valid(n)) { - log_warning("Credential '%s' has invalid name, ignoring.", d->d_name); - continue; - } - - cfd = openat(source_dir_fd, d->d_name, O_RDONLY|O_CLOEXEC); - if (cfd < 0) { - log_warning_errno(errno, "Failed to open %s, ignoring: %m", d->d_name); - continue; - } - - if (fstat(cfd, &st) < 0) { - log_warning_errno(errno, "Failed to stat %s, ignoring: %m", d->d_name); - continue; - } - - r = stat_verify_regular(&st); - if (r < 0) { - log_warning_errno(r, "Credential file %s is not a regular file, ignoring: %m", d->d_name); - continue; - } - - if (!credential_size_ok(&context, n, st.st_size)) - continue; - - r = acquire_credential_directory(&context, ENCRYPTED_SYSTEM_CREDENTIALS_DIRECTORY, /* with_mount= */ false); - if (r < 0) - return r; - - nfd = open_credential_file_for_write(context.target_dir_fd, ENCRYPTED_SYSTEM_CREDENTIALS_DIRECTORY, n); - if (nfd == -EEXIST) - continue; - if (nfd < 0) - return nfd; - - r = copy_bytes(cfd, nfd, st.st_size, 0); - if (r < 0) { - (void) unlinkat(context.target_dir_fd, n, 0); - return log_error_errno(r, "Failed to create credential '%s': %m", n); - } - - context.size_sum += st.st_size; - context.n_credentials++; - - log_debug("Successfully copied boot credential '%s'.", n); - } + r = import_credentials_from_initrd_path( + &encrypted_ctx, p, + ENCRYPTED_SYSTEM_CREDENTIALS_DIRECTORY, + /* with_mount= */ false); + if (r < 0) + return r; } - if (context.n_credentials > 0) { - log_debug("Imported %u credentials from boot loader.", context.n_credentials); + n_system_before = system_ctx->n_credentials; + + r = import_credentials_from_initrd_path( + system_ctx, "/.extra/system_credentials/", + SYSTEM_CREDENTIALS_DIRECTORY, + /* with_mount= */ true); + if (r < 0) + return r; + + if (encrypted_ctx.n_credentials > 0) { + log_debug("Imported %u encrypted credentials from boot loader.", encrypted_ctx.n_credentials); r = finalize_credentials_dir(ENCRYPTED_SYSTEM_CREDENTIALS_DIRECTORY, "ENCRYPTED_CREDENTIALS_DIRECTORY"); if (r < 0) return r; } + if (system_ctx->n_credentials > n_system_before) + log_debug("Imported %u trusted credentials from boot loader.", system_ctx->n_credentials - n_system_before); + + /* The @system credentials directory is shared with import_credentials_trusted(); the caller finalizes. */ + return 0; } @@ -701,28 +740,27 @@ static int import_credentials_initrd(ImportCredentialsContext *c) { return 0; } -static int import_credentials_trusted(void) { - _cleanup_(import_credentials_context_done) ImportCredentialsContext c = { - .target_dir_fd = -EBADF, - }; - int r, ret = 0; +static int import_credentials_trusted(ImportCredentialsContext *c) { + unsigned n_before; + int ret = 0; + + assert(c); /* This is invoked during early boot when no credentials have been imported so far. (Specifically, if * the $CREDENTIALS_DIRECTORY or $ENCRYPTED_CREDENTIALS_DIRECTORY environment variables are not set * yet.) */ - RET_GATHER(ret, import_credentials_qemu(&c)); - RET_GATHER(ret, import_credentials_smbios(&c)); - RET_GATHER(ret, import_credentials_proc_cmdline(&c)); - RET_GATHER(ret, import_credentials_initrd(&c)); + n_before = c->n_credentials; - if (c.n_credentials > 0) { - log_debug("Imported %u credentials from kernel command line/smbios/fw_cfg/initrd.", c.n_credentials); + RET_GATHER(ret, import_credentials_qemu(c)); + RET_GATHER(ret, import_credentials_smbios(c)); + RET_GATHER(ret, import_credentials_proc_cmdline(c)); + RET_GATHER(ret, import_credentials_initrd(c)); - r = finalize_credentials_dir(SYSTEM_CREDENTIALS_DIRECTORY, "CREDENTIALS_DIRECTORY"); - if (r < 0) - return r; - } + if (c->n_credentials > n_before) + log_debug("Imported %u credentials from kernel command line/smbios/fw_cfg/initrd.", c->n_credentials - n_before); + + /* The @system credentials directory is shared with import_credentials_boot(); the caller finalizes. */ return ret; } @@ -884,6 +922,9 @@ int import_credentials(void) { RET_GATHER(r, merge_credentials_trusted(received_creds_dir)); } else { + _cleanup_(import_credentials_context_done) ImportCredentialsContext system_ctx = { + .target_dir_fd = -EBADF, + }; bool import; r = proc_cmdline_get_bool("systemd.import_credentials", PROC_CMDLINE_STRIP_RD_PREFIX|PROC_CMDLINE_TRUE_WHEN_MISSING, &import); @@ -894,8 +935,12 @@ int import_credentials(void) { return 0; } - r = import_credentials_boot(); - RET_GATHER(r, import_credentials_trusted()); + /* System credential context is shared so a single CREDENTIALS_TOTAL_SIZE_MAX is enforced. */ + r = import_credentials_boot(&system_ctx); + RET_GATHER(r, import_credentials_trusted(&system_ctx)); + + if (system_ctx.n_credentials > 0) + RET_GATHER(r, finalize_credentials_dir(SYSTEM_CREDENTIALS_DIRECTORY, "CREDENTIALS_DIRECTORY")); } report_credentials(); From 62fde7bc06fd2d475f9d716edd9a7889f60639fd Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Sat, 23 May 2026 16:25:55 +0200 Subject: [PATCH 2/4] shared: add userspace cpio writer for credentials Add a small newc-format cpio encoder that builds an archive with each credential as a file under .extra/system_credentials/.cred and writes it to a temp file. This mirrors what systemd-stub produces from ESP credentials, so PID1's import_credentials_boot() picks them up unchanged via the new /.extra/system_credentials/ initrd path. Motivated by vmspawn under SEV-SNP, where SMBIOS credentials aren't covered by the launch measurement and are discarded by PID1 in confidential guests, so they must be delivered via the measured initrd instead. The writer lives in src/shared/ so other host-side tooling can reuse it. Signed-off-by: Paul Meyer --- src/shared/initrd-cpio.c | 185 ++++++++++++++++++++++++++++++++++++ src/shared/initrd-cpio.h | 14 +++ src/shared/meson.build | 1 + src/test/meson.build | 1 + src/test/test-initrd-cpio.c | 86 +++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 src/shared/initrd-cpio.c create mode 100644 src/shared/initrd-cpio.h create mode 100644 src/test/test-initrd-cpio.c diff --git a/src/shared/initrd-cpio.c b/src/shared/initrd-cpio.c new file mode 100644 index 0000000000..e19bbcd341 --- /dev/null +++ b/src/shared/initrd-cpio.c @@ -0,0 +1,185 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "alloc-util.h" +#include "creds-util.h" +#include "fd-util.h" +#include "fs-util.h" +#include "initrd-cpio.h" +#include "io-util.h" +#include "log.h" +#include "machine-credential.h" +#include "memory-util.h" +#include "memstream-util.h" +#include "string-util.h" +#include "tmpfile-util.h" + +static void write_cpio_word(FILE *f, uint32_t v) { + assert(f); + + /* Writes a CPIO header 8 character hex value */ + + fprintf(f, "%08" PRIx32, v); +} + +static int append_pad4(FILE *f) { + off_t p; + + assert(f); + + /* Appends NUL bytes until the stream position is a multiple of 4 */ + + p = ftello(f); + if (p < 0) + return -errno; + + for (size_t pad = (4 - ((size_t) p & 3)) & 3; pad > 0; pad--) + fputc(0, f); + + return 0; +} + +static int append_cpio_entry( + FILE *f, + uint32_t mode, /* full mode incl. S_IFDIR or S_IFREG */ + const char *path, + const void *data, /* NULL for directories */ + size_t data_size, /* 0 for directories */ + uint32_t *inode_counter) { + + int r; + + assert(f); + assert(path); + assert(data || data_size == 0); + assert(inode_counter); + + if (data_size > UINT32_MAX) /* cpio cannot deal with > 32-bit file sizes */ + return -EFBIG; + + if (*inode_counter == UINT32_MAX) /* more than 2^32-1 inodes? cpio cannot represent that either */ + return -EOVERFLOW; + + size_t namesize = strlen(path) + 1; + if (namesize > UINT32_MAX) /* cpio also cannot deal with names > 32-bit */ + return -ENAMETOOLONG; + + fputs("070701", f); /* magic ID */ + write_cpio_word(f, (*inode_counter)++); /* inode */ + write_cpio_word(f, mode); /* mode */ + write_cpio_word(f, 0); /* uid */ + write_cpio_word(f, 0); /* gid */ + write_cpio_word(f, 1); /* nlink */ + write_cpio_word(f, 0); /* mtime */ + write_cpio_word(f, data_size); /* size */ + write_cpio_word(f, 0); /* major(dev) */ + write_cpio_word(f, 0); /* minor(dev) */ + write_cpio_word(f, 0); /* major(rdev) */ + write_cpio_word(f, 0); /* minor(rdev) */ + write_cpio_word(f, namesize); /* fname size */ + write_cpio_word(f, 0); /* crc */ + + fwrite(path, 1, namesize, f); + + r = append_pad4(f); + if (r < 0) + return r; + + if (data_size > 0) + fwrite(data, 1, data_size, f); + + return append_pad4(f); +} + +static void append_cpio_trailer(FILE *f) { + static const char trailer[] = + "070701" + "00000000" + "00000000" + "00000000" + "00000000" + "00000001" + "00000000" + "00000000" + "00000000" + "00000000" + "00000000" + "00000000" + "0000000b" + "00000000" + "TRAILER!!!\0\0\0"; /* There's a fourth NUL byte appended here, because this is a string */ + + assert_cc(sizeof(trailer) % 4 == 0); + assert(f); + + fwrite(trailer, 1, sizeof trailer, f); +} + +int initrd_cpio_credentials_to_tempfile( + const MachineCredentialContext *creds, + char **ret_path) { + + _cleanup_(memstream_done) MemStream m = {}; + _cleanup_(unlink_and_freep) char *path = NULL; + _cleanup_close_ int fd = -EBADF; + _cleanup_(erase_and_freep) char *buf = NULL; /* holds plaintext credential bytes; scrub on free */ + size_t buf_size = 0; + uint32_t inode = 1; + FILE *f; + int r; + + assert(creds); + assert(ret_path); + + if (creds->n_credentials == 0) { + *ret_path = NULL; + return 0; + } + + f = memstream_init(&m); + if (!f) + return log_oom(); + + r = append_cpio_entry(f, S_IFDIR | 0555, ".extra", NULL, 0, &inode); + if (r < 0) + return log_error_errno(r, "Failed to write '.extra' directory entry to credentials cpio: %m"); + r = append_cpio_entry(f, S_IFDIR | 0500, ".extra/system_credentials", NULL, 0, &inode); + if (r < 0) + return log_error_errno(r, "Failed to write '.extra/system_credentials' directory entry to credentials cpio: %m"); + + FOREACH_ARRAY(c, creds->credentials, creds->n_credentials) { + if (!credential_name_valid(c->id)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid credential name '%s'.", strna(c->id)); + + _cleanup_free_ char *cpath = strjoin(".extra/system_credentials/", c->id, ".cred"); + if (!cpath) + return log_oom(); + + r = append_cpio_entry(f, S_IFREG | 0400, cpath, c->data, c->size, &inode); + if (r < 0) + return log_error_errno(r, "Failed to write credential '%s' to credentials cpio: %m", c->id); + } + + append_cpio_trailer(f); + + r = memstream_finalize(&m, &buf, &buf_size); + if (r < 0) + return log_error_errno(r, "Failed to finalize credentials cpio: %m"); + + r = tempfn_random_child(NULL, "credentials-cpio", &path); + if (r < 0) + return log_error_errno(r, "Failed to generate temp file name: %m"); + + fd = open(path, O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0600); + if (fd < 0) + return log_error_errno(errno, "Failed to create temp file %s: %m", path); + + r = loop_write(fd, buf, buf_size); + if (r < 0) + return log_error_errno(r, "Failed to write credentials cpio: %m"); + + *ret_path = TAKE_PTR(path); + return 0; +} diff --git a/src/shared/initrd-cpio.h b/src/shared/initrd-cpio.h new file mode 100644 index 0000000000..dd6a737636 --- /dev/null +++ b/src/shared/initrd-cpio.h @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "shared-forward.h" + +/* Builds a new CPIO archive containing each credential as a + * file under .extra/system_credentials/.cred, writes it to a + * freshly-created temp file, and returns the path in *ret_path. + * Caller takes ownership and is responsible for unlink()+free(). + * If creds contains no credentials, no file will be created and + * *ret_path is set to NULL. */ +int initrd_cpio_credentials_to_tempfile( + const MachineCredentialContext *creds, + char **ret_path); diff --git a/src/shared/meson.build b/src/shared/meson.build index 8e874cb99d..2297f8a1af 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -103,6 +103,7 @@ shared_sources = files( 'image-policy.c', 'import-util.c', 'in-addr-prefix-util.c', + 'initrd-cpio.c', 'install-file.c', 'install-printf.c', 'install.c', diff --git a/src/test/meson.build b/src/test/meson.build index 0501ac0b0c..cd54f4d8dc 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -126,6 +126,7 @@ simple_tests += files( 'test-import-util.c', 'test-in-addr-prefix-util.c', 'test-in-addr-util.c', + 'test-initrd-cpio.c', 'test-install-file.c', 'test-install-root.c', 'test-io-util.c', diff --git a/src/test/test-initrd-cpio.c b/src/test/test-initrd-cpio.c new file mode 100644 index 0000000000..cea23a6f1c --- /dev/null +++ b/src/test/test-initrd-cpio.c @@ -0,0 +1,86 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include +#include +#include + +#include "alloc-util.h" +#include "fileio.h" +#include "fs-util.h" +#include "initrd-cpio.h" +#include "machine-credential.h" +#include "path-util.h" +#include "rm-rf.h" +#include "tests.h" +#include "tmpfile-util.h" + +TEST_RET(initrd_cpio_credentials_basic) { + struct stat st; + size_t foo_size, bar_size; + _cleanup_free_ char *cmd = NULL, *extra_path = NULL, *creds_path = NULL, *foo_path = NULL, *bar_path = NULL, *foo_content = NULL, *bar_content = NULL; + _cleanup_(machine_credential_context_done) MachineCredentialContext creds = {}; + _cleanup_(unlink_and_freep) char *cpio_path = NULL; + _cleanup_(rm_rf_physical_and_freep) char *extract_dir = NULL; + int r; + + r = find_executable("cpio", NULL); + if (r < 0) + return log_tests_skipped_errno(r, "Could not find cpio binary: %m"); + + ASSERT_OK(machine_credential_add(&creds, "foo", "hello", 5)); + ASSERT_OK(machine_credential_add(&creds, "bar", "abc\0def", 7)); + + ASSERT_OK(initrd_cpio_credentials_to_tempfile(&creds, &cpio_path)); + ASSERT_NOT_NULL(cpio_path); + + ASSERT_OK(mkdtemp_malloc(NULL, &extract_dir)); + ASSERT_OK(asprintf(&cmd, "cd %s && cpio -idm < %s", extract_dir, cpio_path)); + ASSERT_OK_ZERO_ERRNO(system(cmd)); + + ASSERT_NOT_NULL(extra_path = path_join(extract_dir, ".extra")); + ASSERT_OK_ERRNO(stat(extra_path, &st)); + ASSERT_TRUE(S_ISDIR(st.st_mode)); + ASSERT_EQ((mode_t) (st.st_mode & 07777), (mode_t) 0555); + + ASSERT_NOT_NULL(creds_path = path_join(extract_dir, ".extra/system_credentials")); + ASSERT_OK_ERRNO(stat(creds_path, &st)); + ASSERT_TRUE(S_ISDIR(st.st_mode)); + ASSERT_EQ((mode_t) (st.st_mode & 07777), (mode_t) 0500); + + ASSERT_NOT_NULL(foo_path = path_join(extract_dir, ".extra/system_credentials/foo.cred")); + ASSERT_OK_ERRNO(stat(foo_path, &st)); + ASSERT_TRUE(S_ISREG(st.st_mode)); + ASSERT_EQ((mode_t) (st.st_mode & 07777), (mode_t) 0400); + ASSERT_OK(read_full_file(foo_path, &foo_content, &foo_size)); + ASSERT_EQ(foo_size, 5U); + ASSERT_EQ(memcmp(foo_content, "hello", 5), 0); + + ASSERT_NOT_NULL(bar_path = path_join(extract_dir, ".extra/system_credentials/bar.cred")); + ASSERT_OK_ERRNO(stat(bar_path, &st)); + ASSERT_TRUE(S_ISREG(st.st_mode)); + ASSERT_EQ((mode_t) (st.st_mode & 07777), (mode_t) 0400); + ASSERT_OK(read_full_file(bar_path, &bar_content, &bar_size)); + ASSERT_EQ(bar_size, 7U); + ASSERT_EQ(memcmp(bar_content, "abc\0def", 7), 0); + + return 0; +} + +TEST(initrd_cpio_credentials_rejects_invalid_name) { + _cleanup_(machine_credential_context_done) MachineCredentialContext creds = {}; + _cleanup_(unlink_and_freep) char *cpio_path = NULL; + + /* Bypass the validating machine_credential_add()/_set()/_load() helpers and inject an id that would + * escape the .extra/system_credentials/ directory, to exercise the writer's defense-in-depth check. */ + ASSERT_NOT_NULL(creds.credentials = new0(MachineCredential, 1)); + creds.n_credentials = 1; + ASSERT_NOT_NULL(creds.credentials[0].id = strdup("../../etc/evil")); + ASSERT_NOT_NULL(creds.credentials[0].data = memdup("x", 1)); + creds.credentials[0].size = 1; + + ASSERT_ERROR(initrd_cpio_credentials_to_tempfile(&creds, &cpio_path), EINVAL); + ASSERT_NULL(cpio_path); +} + +DEFINE_TEST_MAIN(LOG_DEBUG); From 76b62b0435c4c0e4f684cac767cc8981bec86a47 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Sat, 23 May 2026 17:05:56 +0200 Subject: [PATCH 3/4] vmspawn: deliver credentials via initrd cpio under SEV-SNP Previously, --load-credential / --set-credential were rejected outright under --coco=sev-snp because the SMBIOS type-11 transport isn't covered by the launch measurement. PID1 wouldn't have accepted those credentials anyway (import_credentials_smbios() refuses any SMBIOS-sourced credentials under a confidential VM). Instead, when SNP is in use and credentials are present, synthesize a newc cpio archive containing each credential at .extra/system_credentials/.cred and append it to the initrd list. The existing merge_initrds() path then concatenates it into the single initrd file QEMU loads, which kernel-hashes=on covers in the SEV-SNP launch digest. PID1's import_credentials_boot() picks them up from the trusted /.extra/system_credentials/ path and routes them to the @system bucket, so units can consume them via LoadCredential= unchanged. Direct kernel boot (--linux=) is already required under SNP, so the initrd is always under our control here. The cpio synthesis happens after all internal machine_credential_add()/machine_credential_load() call sites so the archive captures the complete credential set (journal forwarding, vmm.notify_socket, ssh ephemeral keys, etc.). The cpio path is intentionally scoped to SNP: it requires a guest PID1 that knows about /.extra/system_credentials/, and we don't want to regress credential delivery for non-CoCo guests running older systemd versions in the guest. Consider switching when the new path is widely available. Signed-off-by: Paul Meyer --- src/vmspawn/vmspawn.c | 77 ++++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 36966122f4..b8d42c5587 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -46,6 +46,7 @@ #include "hostname-setup.h" #include "hostname-util.h" #include "id128-util.h" +#include "initrd-cpio.h" #include "kernel-image.h" #include "log.h" #include "machine-bind-user.h" @@ -57,8 +58,8 @@ #include "namespace-util.h" #include "netif-util.h" #include "nsresource.h" -#include "osc-context.h" #include "options.h" +#include "osc-context.h" #include "pager.h" #include "parse-argument.h" #include "parse-util.h" @@ -3424,26 +3425,6 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { } } - char *initrd = NULL; - _cleanup_(rm_rf_physical_and_freep) char *merged_initrd = NULL; - size_t n_initrds = strv_length(arg_initrds); - - if (n_initrds == 1) - initrd = arg_initrds[0]; - else if (n_initrds > 1) { - r = merge_initrds(&merged_initrd); - if (r < 0) - return r; - - initrd = merged_initrd; - } - - if (initrd) { - r = strv_extend_many(&cmdline, "-initrd", initrd); - if (r < 0) - return log_oom(); - } - if (arg_forward_journal) { _cleanup_free_ char *listen_address = NULL; if (asprintf(&listen_address, "vsock:2:%u", child_cid) < 0) @@ -3545,9 +3526,54 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { return log_error_errno(r, "Failed to add VSOCK credential: %m"); } - r = cmdline_add_credentials(&cmdline, smbios_dir_fd, smbios_dir); - if (r < 0) - return r; + /* Under --coco=sev-snp the SMBIOS and fw_cfg channels normally used to deliver credentials are + * not covered by the launch measurement and are silently discarded by the guest PID1 in + * 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 + * 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 && + arg_credentials.n_credentials > 0; + + _cleanup_(unlink_and_freep) char *credentials_cpio_path = NULL; + if (use_initrd_cpio) { + r = initrd_cpio_credentials_to_tempfile(&arg_credentials, &credentials_cpio_path); + if (r < 0) + return r; + r = strv_extend(&arg_initrds, credentials_cpio_path); + if (r < 0) + return log_oom(); + } + + char *initrd = NULL; + _cleanup_(rm_rf_physical_and_freep) char *merged_initrd = NULL; + size_t n_initrds = strv_length(arg_initrds); + + if (n_initrds == 1) + initrd = arg_initrds[0]; + else if (n_initrds > 1) { + r = merge_initrds(&merged_initrd); + if (r < 0) + return r; + + initrd = merged_initrd; + } + + if (initrd) { + r = strv_extend_many(&cmdline, "-initrd", initrd); + if (r < 0) + return log_oom(); + } + + /* Under SNP, credentials flow via the initrd cpio above. For everyone else, use the + * SMBIOS/fw_cfg/cmdline path. */ + if (!use_initrd_cpio) { + r = cmdline_add_credentials(&cmdline, smbios_dir_fd, smbios_dir); + if (r < 0) + return r; + } r = cmdline_add_kernel_cmdline(&cmdline, smbios_dir_fd, smbios_dir); if (r < 0) @@ -4115,9 +4141,6 @@ static int verify_arguments(void) { if (set_contains(arg_firmware_features_include, "secure-boot")) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--secure-boot=yes cannot be combined with --coco."); - if (arg_credentials.n_credentials != 0) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), - "SMBIOS credentials aren't trusted by the confidential computing guest and will be rejected."); 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. From 236bade19c043134ca7c784838099e2aa279ebf7 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Sat, 23 May 2026 17:37:40 +0200 Subject: [PATCH 4/4] man: document SEV-SNP credential delivery via initrd cpio Under --coco=sev-snp, credentials no longer flow through SMBIOS/fw_cfg (which the guest PID1 discards as unmeasured in confidential VMs) but through a cpio archive appended to the initrd, landing in the @system bucket via the new /.extra/system_credentials/ initrd path. Update systemd-vmspawn(1) to describe this and the guest systemd version requirement. Signed-off-by: Paul Meyer --- man/systemd-vmspawn.xml | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/man/systemd-vmspawn.xml b/man/systemd-vmspawn.xml index 3a84196b62..fd73e00770 100644 --- a/man/systemd-vmspawn.xml +++ b/man/systemd-vmspawn.xml @@ -352,13 +352,20 @@ SNP-capable hardware and firmware. must point to a raw SNP-built OVMF .fd image; the standard pflash + NVRAM split is not supported under SNP, so the firmware is loaded via QEMU's and Secure Boot is - unavailable. SMBIOS credentials passed via or - are rejected because they are outside the SNP launch - measurement. Direct kernel boot via is required so that the - kernel, initrd and command line are hashed into the launch measurement - (kernel-hashes=on); booting the kernel off the disk image via the - firmware would leave it outside the measurement. A vTPM, if attached via - , must be treated as untrusted by the guest. + unavailable. Direct kernel boot via is required so that the kernel, + initrd and command line are hashed into the launch measurement + (kernel-hashes=on); booting the kernel off the disk image via the firmware + would leave it outside the measurement. Credentials passed via + or are bundled into a cpio archive appended to the initrd + (mirroring what systemd-stub does for ESP credentials), so they enter the + launch measurement via kernel-hashes=on; the SMBIOS and fw_cfg channels + normally used to deliver credentials are not used because they are unmeasured and would be + discarded by PID1 in confidential guests. This channel is measured but not confidential with + respect to the host or VMM: the initrd (and thus the credentials it carries) is supplied to QEMU + as plaintext and only its hash enters the launch measurement, which guarantees integrity but does + not keep the credentials secret from the host. This requires the guest to run a sufficiently + recent version of systemd (supporting /.extra/system_credentials/). A vTPM, + if attached via , must be treated as untrusted by the guest. @@ -864,6 +871,19 @@ systemd.set_credential_binary= which is not a confidential channel. Do not use this for passing secrets to the VM in that case. + Under , SMBIOS and fw_cfg are not covered by the SNP launch + measurement and are discarded by PID1 in confidential guests. Credentials are therefore packaged + into a cpio archive containing + .extra/system_credentials/ID.cred entries and + appended to the initrd that QEMU loads, so they enter the launch measurement via + kernel-hashes=on. PID1 imports them from the initramfs at boot. As with the + kernel command line, this is a measured but not a confidential channel: QEMU receives the initrd + (and thus the embedded credentials) as plaintext from the host and only its hash is covered by the + launch measurement, so a modified initrd produces a different launch measurement that a relying + party can detect via remote attestation, but the credentials are not hidden from the host or VMM. + This requires the guest to run a sufficiently recent version of systemd (supporting + /.extra/system_credentials/). +