From b98913f4b8c96b657807e08a717407131a6b5a78 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:31:31 +0200 Subject: [PATCH] keyring-setup: accept certificates via system credentials Allow the use of credentials (SMBIOS, ESP credential drop-ins, --set-credential for the boot loader) to ship keys to enroll into keyrings. This lets users avoid having to ship keys as part of the initrd. So accept keyring-setup.. credentials holding one PEM certificate each. We don't enroll them directly but follow the VOA spec instead. Before enrolling they are placed into the ephemeral load path as: /run/voa//kernel-keyring//x509/-certificate.pem This also makes masking, merging and deduplication apply to them like to any other file. Only the certificate is written there, encoded anew, so a private key that came along in the credential doesn't end up world-readable. A credential that isn't a single certificate is bad input. A keyring-setup.os credential overrides the OS identifiers derived from os-release. This is useful for generic initrds whose identity differs from the host's. Signed-off-by: Christian Brauner (Amutable) --- man/systemd-keyring-setup.service.xml | 52 +++++- man/systemd.system-credentials.xml | 29 +++ src/keyring-setup/keyring-setup.rs | 241 +++++++++++++++++++++++-- src/rust/systemd-shared/bindings.h | 1 + units/systemd-keyring-setup.service.in | 4 + 5 files changed, 309 insertions(+), 18 deletions(-) diff --git a/man/systemd-keyring-setup.service.xml b/man/systemd-keyring-setup.service.xml index fad65a43c21..ad674204258 100644 --- a/man/systemd-keyring-setup.service.xml +++ b/man/systemd-keyring-setup.service.xml @@ -129,7 +129,8 @@ debian:13 and debian. If ID= is not set, linux is used. If one of the other fields contains characters that are not permitted by the VOA specification, only the value of ID= is used. If ID= itself - contains such characters, no certificates are looked up. + contains such characters, no certificates are looked up. The keyring-setup.os credential + may be used to specify the identifiers explicitly, see below. Note that the VOA specification requires different semantics than the usual logic for configuration files, where a file in a directory with higher priority replaces a file with the same name in a directory @@ -153,6 +154,52 @@ else. + + Credentials + + systemd-keyring-setup supports the service credentials logic as implemented by + ImportCredential=/LoadCredential=/SetCredential= + (see systemd.exec5 for + details). The following credentials are used when passed in: + + + + keyring-setup.dm-verity.NAME + keyring-setup.fs-verity.NAME + keyring-setup.bpf.NAME + + A PEM-encoded X.509 certificate to enroll into the .dm-verity, + .fs-verity or .bpf keyring, respectively. + NAME may consist of lowercase letters, digits, ., + _ and -. As suggested by the VOA specification for certificates + obtained from elsewhere, the certificate is not enrolled directly, but is first written to + /run/voa/OS/kernel-keyring/CONTEXT/x509/NAME-certificate.pem, + using the first OS identifier. Hence, masking and merging apply to it like to any other file in the + hierarchy. Only the certificate itself is written there, so that a private key included in the + credential is not made readable to other users. A credential that does not contain exactly one + certificate is ignored. + + + + + + keyring-setup.os + + A whitespace-separated list of up to 64 OS identifiers to use instead of the ones + derived from os-release5. + This is useful for generic initrds, whose os-release does not match the one of the + host. If the list is empty, too long or contains an invalid identifier, the credential is ignored and + the identifiers derived from os-release are used. + + + + + + See + systemd.system-credentials7 + for details about system credentials. + + Options @@ -184,7 +231,7 @@ Exit status On success, 0 is returned. This includes the cases where a keyring does not exist, or has been - provisioned by an earlier run already. If a certificate could not be used, for example + provisioned by an earlier run already. If a certificate or a credential could not be used, for example because it is malformed, or because it is not signed by a key in a keyring that has been sealed already, 65 (EX_DATAERR) is returned. systemd-keyring-setup.service treats this exit status as success. On other failures, a different non-zero exit status is returned. @@ -237,6 +284,7 @@ systemd-veritysetup@.service8 systemd-dissect1 systemd-stub7 + systemd.system-credentials7 os-release5 keyctl1 diff --git a/man/systemd.system-credentials.xml b/man/systemd.system-credentials.xml index 1836b4e71bc..633eaeb884a 100644 --- a/man/systemd.system-credentials.xml +++ b/man/systemd.system-credentials.xml @@ -119,6 +119,35 @@ + + keyring-setup.dm-verity.NAME + keyring-setup.fs-verity.NAME + keyring-setup.bpf.NAME + + A PEM-encoded X.509 certificate to enroll into the .dm-verity, + .fs-verity or .bpf kernel keyring, respectively (e.g. + keyring-setup.dm-verity.vendor). Read by + systemd-keyring-setup.service8, + which writes the certificate to /run/voa/ before enrolling it, see there for + details. NAME may consist of lowercase letters, digits, ., + _ and -. + + + + + + + keyring-setup.os + + A whitespace-separated list of OS identifiers (e.g. debian:13 debian) under + which certificates are looked up in the VOA hierarchy, instead of the ones derived from + os-release5. Read by + systemd-keyring-setup.service8. + + + + + login.issue diff --git a/src/keyring-setup/keyring-setup.rs b/src/keyring-setup/keyring-setup.rs index f7e8816c625..e352cf55012 100644 --- a/src/keyring-setup/keyring-setup.rs +++ b/src/keyring-setup/keyring-setup.rs @@ -10,18 +10,20 @@ use core::ffi::{c_int, CStr}; use core::ptr; use core::sync::atomic::Ordering; -use systemd_shared::chase; +use systemd_shared::chase::{self, CHASE_MAX_MODE, CHASE_MKDIR_0755, CHASE_SAFE}; use systemd_shared::conf_files::ConfFile; use systemd_shared::cstr::{self, display}; use systemd_shared::fileio::{self, READ_FULL_FILE_FAIL_WHEN_LARGER, READ_FULL_FILE_VERIFY_REGULAR}; use systemd_shared::keyring::{self, KeySerial}; use systemd_shared::kmod::Kmod; use systemd_shared::prelude::*; +use systemd_shared::recurse_dir::{self, RECURSE_DIR_IGNORE_DOT, RECURSE_DIR_SORT}; use systemd_shared::sys::COMMAND_EXPERIMENTAL; use systemd_shared::table::{Table, TABLE_ERSATZ_DASH}; +use systemd_shared::tmpfile::{LinkableTmpfile, LINK_TMPFILE_REPLACE}; use systemd_shared::voa::{self, Lookup}; use systemd_shared::x509::{self, Der, X509}; -use systemd_shared::{fd, json, sys}; +use systemd_shared::{creds, fd, json, sys}; use systemd_shared::{libcrypto_note, libkmod_note, log_openssl_errors, table_log_add_error}; static ARG_PAGER_FLAGS: AtomicPagerFlags = AtomicPagerFlags::new(0); @@ -43,6 +45,12 @@ const KEYRING_PERM_SEALED: u32 = const CERTIFICATE_SIZE_MAX: usize = 1024 * 1024; +const CREDENTIAL_PREFIX: &str = "keyring-setup."; + +/// Cap the number of possible OSes in both the exact and bare form. 64 OSes is plenty and we can always +/// increase. +const CREDENTIAL_OS_MAX: usize = 64; + struct KeyringSpec { name: &'static CStr, /// The VOA role its certificates come from. @@ -834,6 +842,187 @@ fn process_keyring( Ok(k.data_error) } +/// Credentials are the way to hand a certificate to an initrd without rebuilding it. Following the +/// specification's advice for verifiers retrieved from elsewhere, they are placed into the ephemeral load path +/// as artifact verifiers. Hence, masking, merging and everything else applies to them like to any other file. +/// Returns false if the credential is not a single certificate. +fn materialize_credential( + root: BorrowedFd<'_>, + creds: BorrowedFd<'_>, + cn: &CStr, + spec: &KeyringSpec, + name: &CStr, + os: &Strv, + dry_run: bool, +) -> Result { + // The exact OS identifier suffices + let os = os.iter().next().ok_or(Errno::EINVAL)?; + let path = cstr::try_format(format_args!( + "{}/{}/{}/{}/{}/{}{}", + display(voa::VOA_EPHEMERAL_LOAD_PATH), + display(os), + display(spec.role), + display(spec.context), + display(voa::VOA_TECHNOLOGY_X509), + display(name), + display(voa::VOA_X509_CERTIFICATE_SUFFIX) + )) + .map_err(|_| log_oom!())?; + + if dry_run { + log_info!("Would place credential '{}' at '{path}'.", display(cn)); + return Ok(true); + } + + let contents = creds::read_credential_at(creds, cn) + .map_err(|e| log_error_errno!(e, "Failed to read credential '{}': {e}", display(cn)))?; + + // Only the certificate goes into the world-readable hierarchy, not a private key that came along + let x = match X509::from_pem(&contents) { + Ok((x, false)) => x, + Ok((_, true)) => { + log_warning!( + "Credential '{}' contains more than one certificate, ignoring.", + display(cn) + ); + return Ok(false); + } + Err(Errno::ENOMEM) => return Err(log_oom!()), + Err(e) => { + log_warning_errno!(e, "Failed to parse credential '{}', ignoring: {e}", display(cn)); + return Ok(false); + } + }; + let pem = x + .to_pem() + .map_err(|e| log_error_errno!(e, "Failed to encode credential '{}': {e}", display(cn)))?; + + let (dir, base) = + chase::open_parent_at(root, root, &path, CHASE_MKDIR_0755 | CHASE_SAFE | CHASE_MAX_MODE) + .map_err(|e| log_error_errno!(e, "Failed to create the directory of '{path}': {e}"))?; + + let tmp = LinkableTmpfile::open_at(dir.as_fd(), &base, (sys::O_WRONLY | sys::O_CLOEXEC) as c_int) + .map_err(|e| log_error_errno!(e, "Failed to create '{path}': {e}"))?; + + fd::loop_write(tmp.fd(), pem.as_cstr().to_bytes()) + .map_err(|e| log_error_errno!(e, "Failed to write '{path}': {e}"))?; + + fd::fchmod(tmp.fd(), 0o644) + .map_err(|e| log_error_errno!(e, "Failed to set the mode of '{path}': {e}"))?; + + tmp.link(&base, LINK_TMPFILE_REPLACE) + .map_err(|e| log_error_errno!(e, "Failed to link '{path}' into place: {e}"))?; + + log_debug!("Placed credential '{}' at '{path}'.", display(cn)); + Ok(true) +} + +/// Returns true if a credential could not be used. +fn materialize_credentials( + root: BorrowedFd<'_>, + creds_dir: Result>, + os: &Strv, + args: &Args, +) -> Result { + let creds_dir = match creds_dir { + Ok(fd) => fd, + Err(Errno::ENXIO) => return Ok(false), + Err(e) => return Err(log_error_errno!(e, "Failed to open credentials directory: {e}")), + }; + + if os.is_empty() { + log_warning!("There is no OS identifier to place credentials under, ignoring them."); + return Ok(false); + } + + let de = recurse_dir::readdir_all(creds_dir, RECURSE_DIR_SORT | RECURSE_DIR_IGNORE_DOT) + .map_err(|e| log_error_errno!(e, "Failed to read credentials directory: {e}"))?; + + let mut ret = None; + let mut data_error = false; + for cn in de.names() { + let Some(e) = cstr::strip_prefix(cn, CREDENTIAL_PREFIX.as_bytes()) else { + continue; + }; + if e == c"os" { + continue; + } + + // keyring-setup.., the keyring without its leading dot + let found = KEYRING_SPECS.iter().find_map(|s| { + let name = cstr::strip_prefix(e, s.name.to_bytes().strip_prefix(b".")?)?; + Some((s, cstr::strip_prefix(name, b".")?)) + }); + let Some((spec, name)) = found else { + log_warning!( + "Ignoring unrecognized credential '{}', expected {CREDENTIAL_PREFIX}..", + display(cn) + ); + continue; + }; + if !voa::identifier_is_valid(name, false) { + log_warning!( + "Ignoring credential '{}', the name must consist of lowercase letters, digits, '.', '_' and '-'.", + display(cn) + ); + continue; + } + if !args.selected(spec) { + log_debug!( + "Skipping credential '{}', keyring {} is not selected.", + display(cn), + display(spec.name) + ); + continue; + } + + match materialize_credential(root, creds_dir, cn, spec, name, os, args.dry_run) { + Ok(placed) => data_error |= !placed, + Err(e) => gather(&mut ret, Err(e)), + } + } + + ret.map_or(Ok(data_error), Err) +} + +/// Returns the identifiers if the credential exists. +fn os_from_credential(creds_dir: Result>) -> Result> { + let v = match creds_dir.and_then(|d| creds::read_credential_string_at(d, c"keyring-setup.os")) { + Ok(v) => v, + Err(Errno::ENXIO | Errno::ENOENT) => return Ok(None), + Err(e) => { + return Err(log_warning_errno!( + e, + "Failed to read credential {CREDENTIAL_PREFIX}os, ignoring: {e}" + )); + } + }; + + let l = Strv::split(v.as_cstr(), sys::WHITESPACE, sys::EXTRACT_RETAIN_ESCAPE).map_err(|_| log_oom!())?; + if l.is_empty() { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EINVAL), + "Credential {CREDENTIAL_PREFIX}os is empty, ignoring." + )); + } + if l.len() > CREDENTIAL_OS_MAX { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EINVAL), + "Credential {CREDENTIAL_PREFIX}os lists more than {CREDENTIAL_OS_MAX} OS identifiers, ignoring it." + )); + } + + if let Some(i) = l.iter().find(|i| !voa::os_is_valid(i)) { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EINVAL), + "Invalid OS identifier '{}' in credential {CREDENTIAL_PREFIX}os, ignoring it.", + display(i) + )); + } + + Ok(Some(l)) +} + fn parse_argv(opts: &mut OptionParser<'_>, args: &mut Args) -> Result { foreach_option! { opts, OPTION_COMMON_HELP => return command_print_help!(), @@ -903,24 +1092,44 @@ fn run(argv: Argv<'_>) -> Result { let mut outcome = Outcome::Success; - let os = match voa::os_identifiers(root.as_fd()) { - Ok((l, bare)) => { - if bare { - log_warning!( - "os-release contains characters the VOA specification does not permit, looking up the bare ID only." - ); + // Its users treat its absence differently + let creds = creds::open_credentials_dir_at(root.as_fd()); + let creds_dir = creds.as_ref().map(OwnedFd::as_fd).map_err(|&e| e); + + let os = match os_from_credential(creds_dir) { + Ok(Some(l)) => l, + r => { + if r.is_err() { + // Bad input, but os-release is still there + outcome.data_error(); + } + match voa::os_identifiers(root.as_fd()) { + Ok((l, bare)) => { + if bare { + log_warning!( + "os-release contains characters the VOA specification does not permit, looking up the bare ID only. Pass the {CREDENTIAL_PREFIX}os credential to specify identifiers explicitly." + ); + } + l + } + Err(e) => { + outcome.fail(log_error_errno!( + e, + "Failed to determine the OS identifier from os-release, enrolling nothing: {e}" + )); + Strv::new() + } } - l - } - Err(e) => { - outcome.fail(log_error_errno!( - e, - "Failed to determine the OS identifier from os-release, enrolling nothing: {e}" - )); - Strv::new() } }; + // Sealing does not depend on it either + match materialize_credentials(root.as_fd(), creds_dir, &os, &args) { + Ok(true) => outcome.data_error(), + Ok(false) => {} + Err(e) => outcome.fail(e), + } + let mut t = if args.dry_run { let mut t = Table::new(&[ c"keyring", diff --git a/src/rust/systemd-shared/bindings.h b/src/rust/systemd-shared/bindings.h index 846c02e1ea4..bb6b89b2d24 100644 --- a/src/rust/systemd-shared/bindings.h +++ b/src/rust/systemd-shared/bindings.h @@ -73,6 +73,7 @@ #include "process-util.h" #include "recurse-dir.h" #include "rm-rf.h" +#include "string-util.h" #include "strv.h" #include "terminal-util.h" #include "tests.h" diff --git a/units/systemd-keyring-setup.service.in b/units/systemd-keyring-setup.service.in index 1c8123925be..7904cd4ce7a 100644 --- a/units/systemd-keyring-setup.service.in +++ b/units/systemd-keyring-setup.service.in @@ -26,6 +26,7 @@ ConditionVirtualization=!container Type=oneshot RemainAfterExit=yes SuccessExitStatus=DATAERR +ImportCredential=keyring-setup.* ExecStart={{LIBEXECDIR}}/systemd-keyring-setup # Loading dm_verity is the only privileged operation, the keyrings belong to root anyway @@ -46,6 +47,9 @@ RestrictAddressFamilies=AF_UNIX RestrictNamespaces=yes RestrictRealtime=yes RestrictSUIDSGID=yes +# The ephemeral load path credentials are placed into, kept for the consumers of the hierarchy +RuntimeDirectory=voa +RuntimeDirectoryPreserve=yes SystemCallArchitectures=native SystemCallErrorNumber=EPERM SystemCallFilter=@system-service @module