From 24548be0bfa4d95de93ca940df8c2fe0ca8914b5 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:25:36 +0200 Subject: [PATCH 1/6] keyring-setup: add systemd-keyring-setup The kernel has various keyrings that early userspace may choose to set up. Right now it has the .fs-verity and .dm-verity keyrings and in the next kernel it will also have a .bpf keyring. All three keyrings accept X.509 certificates in DER form. The .dm-verity and .bpf keyrings are inactive and restricted by default unless the respective kernel command line options are passed that leave them unrestricted. Both keyrings are ignored until they are restricted. IOW, restricting them causes the kernel to use them for signature verification. The .fs-verity keyring has slightly different semantics. It is always active and is taken into account as soon as it contains a key. Add systemd-keyring-setup which provisions the keyrings based on the VOA spec from the kernel-keyring role with the keyring as the context: .dm-verity -> kernel-keyring/dm-verity/ .fs-verity -> kernel-keyring/fs-verity/ .bpf -> kernel-keyring/bpf/ Trust anchors are read from the trust-anchor-kernel-keyring/ counterpart. Note that the "trust-anchor-*" prefix is mandated by the spec. The systemd-keyring-setup binary reads /kernel-keyring//x509/*-certificate.pem and the trust-anchor-kernel-keyring/ counterpart from all VOA load paths and enrolls the certificates into the corresponding keyring. Afterwards it restricts the keyring to certificates signed by an enrolled key and drops the SetAttr permission. Hence, neither the restriction nor the mask can be changed anymore. The binary may run in the initrd and on the host. A second run detects a sealed keyring by its permission mask, skips certificates that are enrolled already and enrolls certificates that are signed by an already enrolled key. Any unprovisioned keyrings are sealed at the end of a run even when empty. Failing to enroll a certificate still causes the keyring to be sealed. We can make this more flexible in the future but I don't think leaving a keyring open just because enrolling a key failed makes sense. Note that a file holding more than one certificate is refused. Note that on a second call to add_key() for a certificate with the same subject and key the kernel silently replaces the first. We can detect this by reading the keyring's members before and after enrollment. The binary exits with EX_DATAERR for input that can be fixed and EXIT_FAILURE for kernel refusals. --dry-run only shows what would be enrolled, optionally as JSON. The service isn't enabled by default and the tool is marked experimental for now. systemd-keyring-setup is written in Rust on top of the systemd_shared crate. The VOA lookup, the keyring and X.509 helpers and everything else it uses stay C in libsystemd-shared. All lookups are anchored at the root directory which is opened once. The program, its unit and its man page are only built with Rust support. Signed-off-by: Christian Brauner (Amutable) --- man/rules/meson.build | 4 + man/systemd-keyring-setup.service.xml | 245 +++++++ meson.build | 1 + presets/90-systemd-initrd.preset | 1 + src/include/override/linux/keyctl.h | 5 + src/keyring-setup/keyring-setup.rs | 964 +++++++++++++++++++++++++ src/keyring-setup/meson.build | 14 + units/meson.build | 5 + units/systemd-keyring-setup.service.in | 51 ++ 9 files changed, 1290 insertions(+) create mode 100644 man/systemd-keyring-setup.service.xml create mode 100644 src/keyring-setup/keyring-setup.rs create mode 100644 src/keyring-setup/meson.build create mode 100644 units/systemd-keyring-setup.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index e4459c9a2c9..c0869c4b5f3 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1143,6 +1143,10 @@ manpages = [ 'systemd-journald@.service', 'systemd-journald@.socket'], ''], + ['systemd-keyring-setup.service', + '8', + ['systemd-keyring-setup'], + 'HAVE_OPENSSL HAVE_RUST'], ['systemd-keyutil', '1', [], ''], ['systemd-localed.service', '8', ['systemd-localed'], 'ENABLE_LOCALED'], ['systemd-logind.service', diff --git a/man/systemd-keyring-setup.service.xml b/man/systemd-keyring-setup.service.xml new file mode 100644 index 00000000000..fad65a43c21 --- /dev/null +++ b/man/systemd-keyring-setup.service.xml @@ -0,0 +1,245 @@ + + + + + + + + systemd-keyring-setup.service + systemd + + + + systemd-keyring-setup.service + 8 + + + + systemd-keyring-setup.service + systemd-keyring-setup + Enroll certificates into kernel keyrings + + + + systemd-keyring-setup.service + + /usr/lib/systemd/systemd-keyring-setup + OPTIONS + KEYRING + + + + + + + Description + + systemd-keyring-setup.service is an early boot service that enrolls X.509 + certificates into the kernel keyrings used to verify signatures on dm-verity volumes, fs-verity files and + BPF programs, and then seals these keyrings. The certificates are read from the File + Hierarchy for the Verification of OS Artifacts (VOA). This allows the kernel to verify signatures + with keys provided by the OS vendor or the local administrator, without rebuilding the kernel and without + enrolling the keys into the firmware. + + The following keyrings are provisioned, each from a VOA context of the + kernel-keyring purpose: + + + Keyrings and VOA contexts + + + + + + + Keyring + VOA context + Used to verify + + + + + .dm-verity + dm-verity + Signatures of the root hashes of dm-verity volumes, such as those of Discoverable Disk Images set up by systemd-veritysetup@.service8, systemd-dissect1 or the RootImage= setting of services + + + .fs-verity + fs-verity + Built-in signatures of fs-verity protected files + + + .bpf + bpf + Signatures of BPF programs + + + +
+ + The kernel only uses the .dm-verity and .bpf keyrings once they + have been sealed, and seals them itself while they are still empty, unless + dm_verity.keyring_unsealed=1 or bpf.keyring_unsealed=1 is specified on + the kernel command line, respectively. Hence, these options are required to provision the two keyrings, see + below. The .fs-verity keyring is not sealed by the kernel, and is used as soon as it + contains a key. + + For each keyring that is not sealed yet, the service enrolls the certificates it finds and then seals + the keyring: it installs a restriction so that only certificates signed by a key in the keyring can be added + afterwards, and removes the permission to change this restriction or the permissions of the keyring. The + keyring is sealed even if no certificates were found, or if some of them could not be enrolled. Keys that + were already in the keyring before the service ran are sealed in as well, and a warning is logged about + them. + + Note that kexec will unseal and empty the keyrings. + + The service runs both in the initrd and on the host. Usually, the keyrings are provisioned in the + initrd, since the root file system might itself be a dm-verity volume that is verified with one of the + enrolled keys. When the service runs again on the host, the keyrings have been sealed already. In this case, + only certificates signed by a key that was enrolled earlier can be added, and all other certificates are + rejected. Certificates that have been enrolled already are skipped. +
+ + + Certificate Directories and Precedence + + Certificates are read from the VOA load paths /etc/voa/, + /run/voa/, /usr/local/share/voa/ and + /usr/share/voa/, in order of decreasing priority. For each keyring, artifact verifiers + are read from + OS/kernel-keyring/CONTEXT/x509/NAME-certificate.pem, + and trust anchors from + OS/trust-anchor-kernel-keyring/CONTEXT/x509/NAME-certificate.pem + below each load path. Both are enrolled into the same keyring. NAME may consist + of lowercase letters, digits, + ., _ and -, other files are ignored. Each file must + contain a single PEM-encoded certificate. A file that contains more than one certificate is ignored as a + whole. CA certificates should be placed in separate files among the trust anchors instead. + + Certificates are looked up for two OS identifiers derived from + os-release5: the exact + identifier, and the value of ID= alone. The exact identifier consists of the values of + ID=, VERSION_ID=, VARIANT_ID=, + IMAGE_ID= and IMAGE_VERSION=, separated by :. A + field that is not set is left empty, and unset fields at the end are omitted. For example, + ID=debian and VERSION_ID=13 result in the identifiers + 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. + + 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 + with lower priority. Instead, X.509 certificates are merged: all files in all load paths are used. A + certificate that is found in more than one place is only enrolled once. If two different certificates have + the same subject and key identifier, only one of them can be added to a keyring. In this case, certificates + found for the exact OS identifier take precedence over the ones found for the value of ID= + alone, and among those, the certificate from the load path with the highest priority is enrolled. + + A certificate may be masked by placing a symlink to /dev/null with the same file + name in any of the load paths, usually in /etc/voa/ or /run/voa/. + As required by the VOA specification, a masked certificate is ignored in all load paths, including the + ones with a higher priority than the one containing the symlink. Masking is meant as a substitute for + revocation: if a certificate is considered compromised, a single symlink ensures that none of its copies is + enrolled. Other symlinks are followed as long as they point to a location within the VOA hierarchy, and + symlinks to certificate files only if the target has the same file name. + + To make sure that certificates cannot be placed by unprivileged users, files that are executable or + world-writable, and directories that are world-writable, are ignored. Once a directory owned by an + unprivileged user has been entered, the lookup does not continue into files or directories owned by anyone + else. + + + + Options + + If one or more KEYRING names, such as .dm-verity, are + specified, only those keyrings are processed. Otherwise, all keyrings listed above are processed. + + The following options are understood: + + + + + + Show which certificates would be enrolled into which keyring, and the current state + of the keyrings, without changing anything. + + + + + + + + + + + is only supported together with . + + + + 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 + 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. + + + + Kernel Command Line + + + + dm_verity.keyring_unsealed= + bpf.keyring_unsealed= + + These options are interpreted by the kernel. If set to 1, the kernel + leaves the .dm-verity or .bpf keyring unsealed during boot, so + that it can be provisioned by systemd-keyring-setup.service. Otherwise, the kernel + seals the keyring while it is still empty, and no certificates can be enrolled into it. + + + + + + + + Security Considerations + + The enrolled certificates can only be trusted as much as the files they were read from. The intended + deployment is to provision the keyrings from the initrd of a signed Unified Kernel Image, see + systemd-stub7. + + Until a keyring is sealed, any sufficiently privileged process may add arbitrary keys to it. After it + has been sealed, privileged processes may still remove keys from it, and add certificates that are signed + by a key in the keyring, but they cannot add other keys, or change the restriction or the permissions of + the keyring. The keyrings stay sealed until the next reboot. A soft reboot, see + systemd-soft-reboot.service8, + does not reset them. + + If dm-verity is built as a kernel module, unloading and reloading the module discards the + .dm-verity keyring, including its seal, and creates a new, empty one. If + dm_verity.keyring_unsealed=1 is set, the new keyring may then be provisioned with + arbitrary keys again. systemd-keyring-setup logs a warning if a keyring belongs to a + loadable kernel module. For high-security setups, dm-verity should be built into the kernel. + + + + See Also + + + systemd1 + systemd-veritysetup@.service8 + systemd-dissect1 + systemd-stub7 + os-release5 + keyctl1 + + + +
diff --git a/meson.build b/meson.build index 4bb3bb8047f..d3e24dc868e 100644 --- a/meson.build +++ b/meson.build @@ -2419,6 +2419,7 @@ subdir('src/integritysetup') subdir('src/journal') subdir('src/journal-remote') subdir('src/kernel-install') +subdir('src/keyring-setup') subdir('src/keyutil') subdir('src/locale') subdir('src/login') diff --git a/presets/90-systemd-initrd.preset b/presets/90-systemd-initrd.preset index b7b966daca2..53184ae5e68 100644 --- a/presets/90-systemd-initrd.preset +++ b/presets/90-systemd-initrd.preset @@ -37,4 +37,5 @@ disable systemd-boot-check-no-failures.service disable systemd-journal-gatewayd.* disable systemd-journal-remote.* disable systemd-journal-upload.* +disable systemd-keyring-setup.service disable systemd-time-wait-sync.service diff --git a/src/include/override/linux/keyctl.h b/src/include/override/linux/keyctl.h index 1f0988371a2..41fe4b40bab 100644 --- a/src/include/override/linux/keyctl.h +++ b/src/include/override/linux/keyctl.h @@ -44,3 +44,8 @@ typedef int32_t key_serial_t; #else static_assert(KEY_OTH_ALL == 0x0000003f, ""); #endif + +/* TODO: drop once the headers we require carry it */ +#ifndef KEY_SPEC_BPF_KEYRING +# define KEY_SPEC_BPF_KEYRING -9 /* - key ID for the .bpf keyring */ +#endif diff --git a/src/keyring-setup/keyring-setup.rs b/src/keyring-setup/keyring-setup.rs new file mode 100644 index 00000000000..f7e8816c625 --- /dev/null +++ b/src/keyring-setup/keyring-setup.rs @@ -0,0 +1,964 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +//! systemd-keyring-setup: enrolls the X.509 certificates of the VOA hierarchy into the kernel's trust +//! keyrings and seals them. + +#![no_std] +#![no_main] + +use core::ffi::{c_int, CStr}; +use core::ptr; +use core::sync::atomic::Ordering; + +use systemd_shared::chase; +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::sys::COMMAND_EXPERIMENTAL; +use systemd_shared::table::{Table, TABLE_ERSATZ_DASH}; +use systemd_shared::voa::{self, Lookup}; +use systemd_shared::x509::{self, Der, X509}; +use systemd_shared::{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); + +verbs! { + COMMAND { + names: "systemd-keyring-setup\0", + abstract_: "Enroll certificates from the VOA hierarchy into the kernel's trust keyrings.", + argspec: "[KEYRING…]\0", + man_pages: "systemd-keyring-setup.service(8)\0", + pager_flags: ARG_PAGER_FLAGS, + flags: COMMAND_EXPERIMENTAL, + }, +} + +/// The permission mask we are left with after we dropped SetAttr. +const KEYRING_PERM_SEALED: u32 = + sys::KEY_POS_SEARCH | sys::KEY_USR_VIEW | sys::KEY_USR_READ | sys::KEY_USR_WRITE; + +const CERTIFICATE_SIZE_MAX: usize = 1024 * 1024; + +struct KeyringSpec { + name: &'static CStr, + /// The VOA role its certificates come from. + role: &'static CStr, + /// The VOA context below the role. + context: &'static CStr, + /// The module the kernel's keyring_unsealed= parameter is scoped to, i.e. /sys/module//, None if + /// the kernel never seals the keyring. + param: Option<&'static CStr>, + /// The keyring exists once this module is loaded. + module: Option<&'static CStr>, + /// Fixed KEY_SPEC_* ID, None if the keyring only goes by name. + spec_id: Option, +} + +static KEYRING_SPECS: [KeyringSpec; 3] = [ + KeyringSpec { + name: c".dm-verity", + role: c"kernel-keyring", + context: c"dm-verity", + param: Some(c"dm_verity"), + module: Some(c"dm_verity"), + spec_id: None, + }, + KeyringSpec { + name: c".fs-verity", + role: c"kernel-keyring", + context: c"fs-verity", + param: None, + module: None, + spec_id: None, + }, + KeyringSpec { + name: c".bpf", + role: c"kernel-keyring", + context: c"bpf", + param: Some(c"bpf"), + module: None, + spec_id: Some(sys::KEY_SPEC_BPF_KEYRING), + }, +]; + +fn keyring_spec_from_name(name: &CStr) -> Option<&'static KeyringSpec> { + KEYRING_SPECS.iter().find(|s| s.name == name) +} + +/// What the command line asks for. +struct Args { + keyrings: Vec<&'static KeyringSpec>, + dry_run: bool, + json_format_flags: sys::sd_json_format_flags_t, +} + +impl Args { + fn selected(&self, spec: &KeyringSpec) -> bool { + self.keyrings.is_empty() || self.keyrings.iter().any(|s| ptr::eq(*s, spec)) + } +} + +/// What a run amounts to, the exit status: a hard failure outweighs input that could not be used. +#[derive(Clone, Copy)] +enum Outcome { + Success, + DataError, + Failed(Errno), +} + +impl Outcome { + fn data_error(&mut self) { + if let Outcome::Success = self { + *self = Outcome::DataError; + } + } + + fn fail(&mut self, e: Errno) { + if !matches!(self, Outcome::Failed(_)) { + *self = Outcome::Failed(e); + } + } +} + +/// Keeps the first error, `RET_GATHER()`. +fn gather(ret: &mut Option, r: Result<()>) { + if let Err(e) = r { + ret.get_or_insert(e); + } +} + +struct Certificate { + path: OwnedCStr, + der: Der, + /// The ": " tail of the kernel's description. + description_suffix: OwnedCStr, + /// None until enrolled. + serial: Option, +} + +/// A key linked into the keyring. +struct Member { + serial: KeySerial, + /// None if the key could not be described. + description: Option, +} + +struct Keyring { + spec: &'static KeyringSpec, + /// 0 while unknown. + serial: KeySerial, + /// None while unknown, a dry run cannot load the module providing it. + exists: Option, + /// The boot-time flag, None if unknown. + kernel_unsealed: Option, + certificates: Vec, + /// As last read, None if they could not be read. + members: Option>, + sealed: bool, + /// A certificate could not be used. + data_error: bool, + /// A hard error that does not stop the sealing. + failed: Option, +} + +impl Keyring { + fn new(spec: &'static KeyringSpec) -> Keyring { + Keyring { + spec, + serial: 0, + exists: None, + kernel_unsealed: None, + certificates: Vec::new(), + members: None, + sealed: false, + data_error: false, + failed: None, + } + } + + fn name(&self) -> impl core::fmt::Display + '_ { + display(self.spec.name) + } + + fn members(&self) -> &[Member] { + self.members.as_deref().unwrap_or_default() + } + + /// The certificates this run enrolled that are still in the keyring. + fn n_enrolled(&self) -> usize { + self.certificates.iter().filter(|c| c.serial.is_some()).count() + } + + /// Reads the members along with their descriptions, they are unknown if that fails. + fn read_members(&mut self) -> Result<()> { + self.members = None; + + let serials = keyring::list(self.serial)?; + let mut members = Vec::with_capacity(serials.len())?; + for &serial in &serials { + // A key that cannot be described still counts, it just matches nothing + let description = keyring::describe_full(serial) + .map_err(|e| { + log_debug_errno!( + e, + "Failed to describe key {} of keyring {}, ignoring: {e}", + serial, + self.name() + ) + }) + .ok(); + members.push(Member { serial, description })?; + } + + self.members = Some(members); + Ok(()) + } +} + +/// The kernel describes a certificate as ": ", with the subject key identifier or, lacking one, +/// the serial number as hex. Returns the ": " part, separator included. +fn certificate_description_suffix(x: &X509) -> Result { + let (id, serial) = match x.subject_key_id() { + Some(id) => (id, false), + None => (x.serial_number(), true), + }; + + // For a certificate with no subjectKeyIdentifier and a negative serial number certificate_is_enrolled() + // will not be able to match the key the kernel created. So on every run we would readd the certificate. + // And once the keyring is sealed this would also fail. While such certificates are forbidden per RFC 5280 + // they can still be created via openssl x509 -req -set_serial -1. For such certificates openssl stores a + // negative V_ASN1_NEG_INTEGER plus the absolute value in ->data. Detect such certificates and reject them. + if serial && id.type_() == sys::V_ASN1_NEG_INTEGER as c_int { + return Err(Errno::EBADMSG); + } + + let d = id.data(); + let Some(first) = d.first() else { + return Err(Errno::EBADMSG); + }; + + // DER pads a serial with the top bit set with a zero byte, which the kernel keeps + let pad = if serial && first & 0x80 != 0 { "00" } else { "" }; + + cstr::try_format(format_args!(": {pad}{}", Hex(d))) +} + +/// Lowercase hex, formatted without allocating. +struct Hex<'a>(&'a [u8]); + +impl core::fmt::Display for Hex<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.iter().try_for_each(|b| write!(f, "{b:02x}")) + } +} + +/// Returns true if the certificate was added, false if the same certificate was found before. +fn load_certificate(k: &mut Keyring, f: &ConfFile) -> Result { + let path = f.original_path(); + + let text = f + .fd() + .ok_or(Errno::EBADF) + .and_then(|fd| { + fileio::read_full_file_full( + fd, + None, + u64::MAX, + CERTIFICATE_SIZE_MAX, + READ_FULL_FILE_FAIL_WHEN_LARGER | READ_FULL_FILE_VERIFY_REGULAR, + ) + }) + .map_err(|e| log_warning_errno!(e, "Failed to read '{}', ignoring: {e}", display(path)))?; + if text.is_empty() { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EBADMSG), + "'{}' is empty, ignoring.", + display(path) + )); + } + + let (x, more) = X509::from_pem(&text) + .map_err(|e| log_warning_errno!(e, "Failed to parse '{}', ignoring: {e}", display(path)))?; + + // Don't allow certificate bundles. + if more { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EINVAL), + "'{}' contains more than one certificate, ignoring. Store one certificate per file, CA certificates below trust-anchor-{}/.", + display(path), + display(k.spec.role) + )); + } + + // The kernel takes DER + let Some(der) = x.to_der() else { + return Err(log_openssl_errors!( + LOG_WARNING, + "Failed to encode '{}', ignoring", + display(path) + )); + }; + + // Every copy of a verifier counts, but the same certificate is enrolled once + if let Some(c) = k.certificates.iter().find(|c| *c.der == *der) { + log_debug!( + "'{}' is identical to '{}', skipping.", + display(path), + display(&c.path) + ); + return Ok(false); + } + + let description_suffix = certificate_description_suffix(&x).map_err(|e| { + log_warning_errno!( + e, + "Failed to determine the description of '{}', ignoring: {e}", + display(path) + ) + })?; + + let path = OwnedCStr::try_from(path).map_err(|_| log_oom!())?; + k.certificates + .push(Certificate { + path, + der, + description_suffix, + serial: None, + }) + .map_err(|_| log_oom!())?; + + Ok(true) +} + +fn collect_certificates(k: &mut Keyring, root: BorrowedFd<'_>, os: &Strv) -> Result<()> { + // Without an identifier there is nothing to look up, the failure was reported once already and the keyring + // is still sealed + if os.is_empty() { + log_debug!("No OS identifier, collecting nothing for keyring {}.", k.name()); + return Ok(()); + } + + // Trust anchors and artifact verifiers end up in the same flat keyring, the kernel validates chains itself + for mode in [voa::VOA_MODE_ARTIFACT_VERIFIER, voa::VOA_MODE_TRUST_ANCHOR] { + let lookup = Lookup { + os, + role: k.spec.role, + context: k.spec.context, + mode, + technology: voa::VOA_TECHNOLOGY_X509, + suffix: voa::VOA_X509_CERTIFICATE_SUFFIX, + }; + + let files = voa::list_verifiers(root, &lookup, voa::VOA_WARN).map_err(|e| { + log_error_errno!( + e, + "Failed to enumerate certificates for keyring {}: {e}", + k.name() + ) + })?; + + for f in files.iter() { + match load_certificate(k, f) { + Err(e @ Errno::ENOMEM) => return Err(e), + Err(_) => k.data_error = true, + Ok(_) => {} + } + } + } + + Ok(()) +} + +/// /sys/module// below root. +fn open_module_dir(root: BorrowedFd<'_>, module: &CStr) -> Result { + let flags = (sys::O_PATH | sys::O_DIRECTORY | sys::O_CLOEXEC) as c_int; + let modules = chase::openat(root, root, c"/sys/module", 0, flags)?; + chase::openat(root, modules.as_fd(), module, 0, flags) +} + +/// Returns true if the keyring exists and may be provisioned, false if there is nothing to do. +fn locate_keyring( + k: &mut Keyring, + root: BorrowedFd<'_>, + kmod: Option<&Kmod>, + dry_run: bool, +) -> Result { + let mut module_loaded = true; + + // The keyring exists once the module is loaded. Like modprobe, libkmod applies the parameters from the + // kernel command line. + if let Some(module) = k.spec.module { + if dry_run { + // A dry run may not load the module and without it being loaded it has no way of figuring out whether + // the required keyring exists. + module_loaded = open_module_dir(root, module).is_ok(); + } else if let Some(kmod) = kmod { + let _ = kmod.load_and_warn(module, false); + } + } + + let r = match k.spec.spec_id { + // Kernels without the keyring do not know its ID + Some(id) => keyring::resolve(id).map_err(|e| match e { + Errno::EINVAL => Errno::ENOKEY, + e => e, + }), + None => keyring::find_by_name_at(root, k.spec.name, 0), + }; + k.serial = match r { + Ok(serial) => serial, + Err(e @ Errno::ENOKEY) => { + // Without the module loaded the keyring cannot exist yet, a dry run cannot tell + k.exists = module_loaded.then_some(false); + + if k.certificates.is_empty() { + log_debug_errno!(e, "Keyring {} not found, nothing to do: {e}", k.name()); + } else { + log_notice!( + "Keyring {} not found, {} certificate(s) cannot be enrolled.", + k.name(), + k.certificates.len() + ); + } + return Ok(false); + } + Err(e @ Errno::ENOTUNIQ) => { + return Err(log_error_errno!( + e, + "More than one keyring named {}, refusing.", + k.name() + )); + } + Err(e @ Errno::ERFKILL) => { + return Err(log_error_errno!( + e, + "Kernel keyrings are not accessible here, /proc/keys is masked." + )); + } + Err(e) => return Err(log_error_errno!(e, "Failed to look up keyring {}: {e}", k.name())), + }; + + k.exists = Some(true); + + let perm = keyring::perm(k.serial) + .map_err(|e| log_error_errno!(e, "Failed to read permissions of keyring {}: {e}", k.name()))?; + + // Restriction state is not observable only the permission mask is. So use that to figure out whether a + // keyring is sealed. + if perm == KEYRING_PERM_SEALED { + log_debug!("Keyring {} is sealed already.", k.name()); + k.sealed = true; + } else if perm & sys::KEY_USR_SETATTR == 0 { + // If SetAttr is missing neither the permission mask nor the restrictions can be changed. We don't know + // what to do with that. So fail. + return Err(log_error_errno!( + SYNTHETIC_ERRNO(EPERM), + "Keyring {} has permission mask 0x{perm:08x} not set by us and SetAttr is gone, cannot repair it.", + k.name() + )); + } else if perm & sys::KEY_USR_WRITE == 0 { + return Err(log_error_errno!( + SYNTHETIC_ERRNO(EPERM), + "Keyring {} has an unexpected permission mask 0x{perm:08x}, not touching it.", + k.name() + )); + } + + if let Err(e) = k.read_members() { + log_debug_errno!(e, "Failed to read keyring {}, ignoring: {e}", k.name()); + } + + Ok(true) +} + +/// Returns true if the keyring may be provisioned, false if the kernel sealed it at boot. +fn check_unsealed(k: &mut Keyring, root: BorrowedFd<'_>) -> bool { + let Some(param) = k.spec.param else { + return true; + }; + + let unsealed = open_module_dir(root, param) + .and_then(|d| chase::open_parent_at(root, d.as_fd(), c"parameters/keyring_unsealed", 0)) + .and_then(|(dir, name)| fileio::read_boolean_file_at(dir.as_fd(), &name)); + let unsealed = match unsealed { + Ok(unsealed) => unsealed, + Err(e) => { + // A sealed keyring refuses additions, hence enrollment tells the state, and a keyring the kernel + // sealed at boot is then sealed like any other + log_debug_errno!( + e, + "Cannot read keyring_unsealed parameter of {}, ignoring: {e}", + display(param) + ); + return true; + } + }; + k.kernel_unsealed = Some(unsealed); + + if !unsealed { + if k.certificates.is_empty() { + log_debug!("Keyring {} was sealed at boot, nothing to do.", k.name()); + } else { + log_notice!( + "Keyring {} was sealed at boot, {} certificate(s) cannot be enrolled. Pass {}.keyring_unsealed=1 on the kernel command line to provision it.", + k.name(), + k.certificates.len(), + display(param) + ); + } + + return false; + } + + if k.certificates.is_empty() { + log_warning!( + "Keyring {} was left unsealed at boot, but no certificates are configured for it.", + k.name() + ); + } + + true +} + +fn certificate_is_enrolled(members: &[Member], c: &Certificate) -> bool { + // For asymmetric keys the kernel creates a description of the form ": " if the key is added + // without a description. For other key types the description is whatever the creator of the key added. + // Such descriptions must not count as a match. + members.iter().any(|m| { + m.description.as_ref().is_some_and(|d| { + d.type_.as_cstr() == c"asymmetric" + && d.description + .to_bytes() + .ends_with(c.description_suffix.as_cstr().to_bytes()) + }) + }) +} + +/// Drops only what this invocation enrolled, so the tool may be called multiple times without wasting anyone +/// else's keys. +fn unlink_enrolled(k: &mut Keyring) -> Result<()> { + let mut r = None; + + for c in &mut k.certificates { + let Some(serial) = c.serial else { continue }; + let q = match keyring::unlink_key(k.serial, serial) { + // Superseded by a later certificate, the kernel dropped the link already + Err(Errno::ENOENT | Errno::ENOKEY) => Ok(()), + q => q, + }; + if q.is_ok() { + c.serial = None; + } + gather(&mut r, q); + } + + match r { + Some(e) => Err(log_error_errno!( + e, + "Failed to remove the enrolled keys from keyring {}: {e}", + k.name() + )), + None => Ok(()), + } +} + +fn enroll_certificates(k: &mut Keyring) { + let hint = k.spec.param.filter(|_| k.kernel_unsealed.is_none()); + + // Not enrolled by this run, but sealed in with the rest. Under a seal the restriction vetted them. + if !k.sealed { + for m in k.members() { + let description = m.description.as_ref().map(|d| &*d.description); + log_warning!( + "Keyring {} already held key {}{}{}, it is sealed in as well.", + k.name(), + m.serial, + if description.is_some() { ": " } else { "" }, + display(description.unwrap_or_default()) + ); + } + } + + // In reverse: on a colliding subject and key identifier the kernel replaces the key, so the copy from the + // highest-priority load path and the most specific OS identifier is enrolled last and wins. + for i in (0..k.certificates.len()).rev() { + let c = &k.certificates[i]; + + if certificate_is_enrolled(k.members(), c) { + log_debug!( + "'{}' is in keyring {} already, skipping.", + display(&c.path), + k.name() + ); + continue; + } + + let serial = match keyring::add_asymmetric(k.serial, None, &c.der) { + Ok(serial) => serial, + Err(Errno::EPERM) => { + // Sealed by the kernel at boot. Nothing can be added, but the mask can still be dropped. + match hint { + Some(param) => log_notice!( + "Keyring {} is sealed, cannot enroll '{}'. Pass {}.keyring_unsealed=1 on the kernel command line to provision it.", + k.name(), + display(&c.path), + display(param) + ), + None => log_notice!( + "Keyring {} is sealed, cannot enroll '{}'.", + k.name(), + display(&c.path) + ), + } + break; + } + Err( + e + @ (Errno::EACCES | Errno::EDQUOT | Errno::ENOMEM | Errno::EKEYREVOKED | Errno::EKEYEXPIRED), + ) => { + // The keyring itself is the problem, sealing it is all that is left. What went in so far is the + // low-priority tail, it must not be sealed in alone. + log_error_errno!( + e, + "Failed to enroll '{}' into keyring {}, giving up: {e}", + display(&c.path), + k.name() + ); + gather(&mut k.failed, Err(e)); + let _ = unlink_enrolled(k); + break; + } + Err(e) => { + match e { + Errno::ENOKEY => log_warning_errno!( + e, + "Keyring {} is sealed and '{}' is not signed by a key enrolled in it: {e}", + k.name(), + display(&c.path) + ), + Errno::EKEYREJECTED => log_warning_errno!( + e, + "Kernel rejected certificate '{}' (blacklisted, or its signature does not verify): {e}", + display(&c.path) + ), + Errno::ENOPKG => log_warning_errno!( + e, + "Kernel does not support the public key algorithm of '{}': {e}", + display(&c.path) + ), + _ => log_warning_errno!(e, "Failed to enroll '{}': {e}", display(&c.path)), + }; + + k.data_error = true; + continue; + } + }; + + k.certificates[i].serial = Some(serial); + + let description = keyring::description(serial) + .map_err(|e| log_debug_errno!(e, "Failed to describe enrolled key, ignoring: {e}")) + .ok(); + + log_info!( + "Enrolled '{}' into keyring {}{}{}.", + display(&k.certificates[i].path), + k.name(), + if description.is_some() { " as " } else { "" }, + display(description.as_deref().unwrap_or_default()) + ); + } + + // A certificate with the same subject and key identifier as a key in the keyring replaces it, which only + // reading the members again reveals + if let Err(e) = k.read_members() { + log_debug_errno!(e, "Failed to read keyring {}, ignoring: {e}", k.name()); + return; + } + + let members = k.members.as_deref().unwrap_or_default(); + for c in &mut k.certificates { + if c.serial.is_some_and(|s| !members.iter().any(|m| m.serial == s)) { + log_info!( + "'{}' was superseded by a certificate of higher priority with the same subject and key identifier.", + display(&c.path) + ); + c.serial = None; + } + } +} + +fn seal_keyring(k: &mut Keyring, root: BorrowedFd<'_>) -> Result<()> { + // Sealing only allows certificates signed by a key already in the keyring to be added. Dropping SetAttr + // makes the restriction and mask immutable. The read and write permissions have to stay so keys can still + // be enrolled. + if k.sealed { + return Ok(()); + } + + // Needs the setattr permission that goes away below + match keyring::restrict(k.serial, Some(c"asymmetric"), Some(c"key_or_keyring:0:chain")) { + Ok(()) => {} + Err(Errno::EEXIST) => { + // Someone else already restricted this keyring. + log_warning!("Keyring {} carries a restriction not installed by us.", k.name()); + k.data_error = true; + } + Err(e) => { + // If we fail to enroll certificates drop every certificate we have enrolled in this invocation. We + // don't drop anything else so this tool may be called multiple times without wasting anyone else's + // keys. + let q = unlink_enrolled(k); + return Err(log_error_errno!( + e, + "Failed to restrict keyring {}, leaving it unsealed and writable{}: {e}", + k.name(), + if q.is_err() { + " with keys enrolled" + } else { + ", the enrolled keys removed" + } + )); + } + } + + // Note that if we fail to restrict the keyring above we also skip permission changes. If we were to remove + // permissions we would lose the ability to restrict the keyring completely. + keyring::set_perm(k.serial, KEYRING_PERM_SEALED) + .map_err(|e| log_error_errno!(e, "Failed to drop permissions of keyring {}: {e}", k.name()))?; + + k.sealed = true; + + match &k.members { + Some(members) => log_info!( + "Sealed keyring {} with {} key(s), {} enrolled now.", + k.name(), + members.len(), + k.n_enrolled() + ), + None => log_info!("Sealed keyring {}, {} enrolled now.", k.name(), k.n_enrolled()), + } + + // Unloading a kernel module may destroy the keyring and circumvent the security model. Warn about that. + if let Some(module) = k.spec.module { + let loadable = open_module_dir(root, module) + .and_then(|d| chase::accessat(root, d.as_fd(), c"refcnt", 0, sys::F_OK as c_int)); + if loadable.is_ok() { + log_warning!( + "Keyring {} belongs to the loadable {} module: unloading the module may discard the keyring and its seal, and reloading it creates one that can be provisioned again. Build {} into the kernel to keep the enrolled set for the lifetime of the kernel.", + k.name(), + display(module), + display(module) + ); + } + } + + Ok(()) +} + +fn add_row(t: &mut Table, k: &Keyring, paths: &Strv) -> Result<()> { + t.add_string(k.spec.name)?; + t.add_string(k.spec.context)?; + match k.exists { + Some(exists) => t.add_boolean_checkmark(exists)?, + None => t.add_empty()?, + } + t.add_tristate(k.kernel_unsealed)?; + match &k.members { + Some(members) => t.add_uint64(members.len() as u64)?, + None => t.add_empty()?, + } + t.add_boolean_checkmark(k.sealed)?; + t.add_strv(paths) +} + +fn add_to_table(t: &mut Table, k: &Keyring) -> Result<()> { + let mut paths = Strv::new(); + for c in &k.certificates { + paths.push(&c.path).map_err(|_| log_oom!())?; + } + + add_row(t, k, &paths).map_err(|e| table_log_add_error!(e)) +} + +/// Returns true if a certificate could not be used. +fn process_keyring( + kmod: Option<&Kmod>, + spec: &'static KeyringSpec, + root: BorrowedFd<'_>, + os: &Strv, + t: Option<&mut Table>, + args: &Args, +) -> Result { + let mut k = Keyring::new(spec); + + // An error enumerating the certificates does not stop the sealing + let r = collect_certificates(&mut k, root, os); + gather(&mut k.failed, r); + + let mut r = locate_keyring(&mut k, root, kmod, args.dry_run); + if matches!(r, Ok(true)) { + r = Ok(check_unsealed(&mut k, root)); + } + + if args.dry_run { + if matches!(r, Ok(true)) { + let n = k + .certificates + .iter() + .filter(|c| !certificate_is_enrolled(k.members(), c)) + .count(); + log_info!("Would enroll {} certificate(s) into keyring {}.", n, k.name()); + } + + if let Some(t) = t { + let q = add_to_table(t, &k); + if r.is_ok() { + q?; + } + } + } else if matches!(r, Ok(true)) { + enroll_certificates(&mut k); + r = seal_keyring(&mut k, root).map(|()| true); + } + + r?; + if let Some(e) = k.failed { + return Err(e); + } + + Ok(k.data_error) +} + +fn parse_argv(opts: &mut OptionParser<'_>, args: &mut Args) -> Result { + foreach_option! { opts, + OPTION_COMMON_HELP => return command_print_help!(), + OPTION_COMMON_VERSION => return version(), + OPTION_LONG("dry-run", None, "Only show what would be enrolled") => args.dry_run = true, + OPTION_COMMON_NO_PAGER => { + ARG_PAGER_FLAGS.fetch_or(sys::PAGER_DISABLE, Ordering::Relaxed); + }, + OPTION_COMMON_JSON => { + let r = json::parse_argument(opts.arg(), &mut args.json_format_flags)?; + if r <= 0 { + return Ok(r); + } + }, + OPTION_COMMON_INTROSPECT_CLI => return introspect_cli!(sys::SD_JSON_FORMAT_OFF), + } + + for a in opts.args().iter() { + let Some(spec) = keyring_spec_from_name(a) else { + return Err(log_error_errno!( + SYNTHETIC_ERRNO(EINVAL), + "Unknown keyring: '{}'", + display(a) + )); + }; + args.keyrings.push(spec).map_err(|_| log_oom!())?; + } + + if json::format_enabled(args.json_format_flags) && !args.dry_run { + return Err(log_error_errno!( + SYNTHETIC_ERRNO(EINVAL), + "--json= is only supported together with --dry-run." + )); + } + + Ok(1) +} + +fn run(argv: Argv<'_>) -> Result { + let mut args = Args { + keyrings: Vec::new(), + dry_run: false, + json_format_flags: sys::SD_JSON_FORMAT_OFF, + }; + + let mut opts = OptionParser::new(argv); + if parse_argv(&mut opts, &mut args)? <= 0 { + return Ok(0); + } + + libcrypto_note!(required); + x509::dlopen_libcrypto(LOG_ERR)?; + + libkmod_note!(recommended); + #[cfg(HAVE_KMOD)] + let kmod = Kmod::setup() + .map_err(|e| log_debug_errno!(e, "Failed to initialize libkmod, not loading kernel modules: {e}")) + .ok(); + #[cfg(not(HAVE_KMOD))] + let kmod: Option = None; + + let root = fd::reopen( + BorrowedFd::XAT_FDROOT, + (sys::O_PATH | sys::O_DIRECTORY | sys::O_CLOEXEC) as c_int, + ) + .map_err(|e| log_error_errno!(e, "Failed to open root directory: {e}"))?; + + 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." + ); + } + l + } + Err(e) => { + outcome.fail(log_error_errno!( + e, + "Failed to determine the OS identifier from os-release, enrolling nothing: {e}" + )); + Strv::new() + } + }; + + let mut t = if args.dry_run { + let mut t = Table::new(&[ + c"keyring", + c"context", + c"exists", + c"kernel unsealed", + c"keys", + c"sealed", + c"certificates", + ]) + .map_err(|_| log_oom!())?; + t.set_ersatz_string(TABLE_ERSATZ_DASH); + Some(t) + } else { + None + }; + + for spec in KEYRING_SPECS.iter().filter(|s| args.selected(s)) { + match process_keyring(kmod.as_ref(), spec, root.as_fd(), &os, t.as_mut(), &args) { + Ok(true) => outcome.data_error(), + Ok(false) => {} + Err(e) => outcome.fail(e), + } + } + + if let Some(t) = &t { + t.print_with_pager( + args.json_format_flags, + ARG_PAGER_FLAGS.load(Ordering::Relaxed), + true, + )?; + } + + match outcome { + Outcome::Success => Ok(0), + Outcome::DataError => Ok(sys::EX_DATAERR as c_int), + Outcome::Failed(e) => Err(e), + } +} + +define_main_with_positive_failure!(run); diff --git a/src/keyring-setup/meson.build b/src/keyring-setup/meson.build new file mode 100644 index 00000000000..3ad1dc11847 --- /dev/null +++ b/src/keyring-setup/meson.build @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +if not have_rust + subdir_done() +endif + +executables += [ + rust_executable_template + { + 'name' : 'systemd-keyring-setup', + 'sources' : files('keyring-setup.rs'), + 'install_dir' : libexecdir, + 'conditions' : ['HAVE_OPENSSL'], + }, +] diff --git a/units/meson.build b/units/meson.build index 6cdb101a397..4d1fa734250 100644 --- a/units/meson.build +++ b/units/meson.build @@ -546,6 +546,11 @@ units = [ { 'file' : 'systemd-journald@.service.in' }, { 'file' : 'systemd-journald@.socket' }, { 'file' : 'systemd-kexec.service' }, + { + 'file' : 'systemd-keyring-setup.service.in', + 'conditions' : ['HAVE_OPENSSL', 'HAVE_RUST'], + 'symlinks' : ['sysinit.target.wants/'], + }, { 'file' : 'systemd-localed.service.in', 'conditions' : ['ENABLE_LOCALED'], diff --git a/units/systemd-keyring-setup.service.in b/units/systemd-keyring-setup.service.in new file mode 100644 index 00000000000..1c8123925be --- /dev/null +++ b/units/systemd-keyring-setup.service.in @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Enroll Certificates into Kernel Keyrings +Documentation=man:systemd-keyring-setup.service(8) + +DefaultDependencies=no +Conflicts=shutdown.target initrd-switch-root.target +Before=sysinit.target shutdown.target initrd-switch-root.target +# Verity devices are set up after veritysetup-pre.target, everything mounting images after local-fs-pre.target +Before=local-fs-pre.target veritysetup-pre.target +Wants=local-fs-pre.target veritysetup-pre.target +Before=systemd-sysext-initrd.service systemd-confext-initrd.service systemd-mountfsd.service + +# A privileged container sharing the initial user namespace could otherwise provision the host's keyrings +ConditionVirtualization=!container + +[Service] +Type=oneshot +RemainAfterExit=yes +SuccessExitStatus=DATAERR +ExecStart={{LIBEXECDIR}}/systemd-keyring-setup + +# Loading dm_verity is the only privileged operation, the keyrings belong to root anyway +CapabilityBoundingSet=CAP_SYS_MODULE +IPAddressDeny=any +LockPersonality=yes +MemoryDenyWriteExecute=yes +NoNewPrivileges=yes +PrivateDevices=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +SystemCallArchitectures=native +SystemCallErrorNumber=EPERM +SystemCallFilter=@system-service @module From b98913f4b8c96b657807e08a717407131a6b5a78 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:31:31 +0200 Subject: [PATCH 2/6] 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 From fb6167ca1fbd31199d70ba5e64ca060f8e646d5e Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:36:07 +0200 Subject: [PATCH 3/6] keyring-setup: check certificate usage against the VOA purpose The specification requires the extended key usage of a verifier to be aligned with its purpose. The kernel has no concept of this. So do it in systemd-keyring-setup. An artifact verifier whose extended key usage permits neither code signing nor any usage is rejected. A trust anchor that is explicitly not a CA is still enrolled and only logged at notice level because self-signed leaf certificates doubling as their own anchor are common. Signed-off-by: Christian Brauner (Amutable) --- man/systemd-keyring-setup.service.xml | 11 +++++- src/keyring-setup/keyring-setup.rs | 53 +++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/man/systemd-keyring-setup.service.xml b/man/systemd-keyring-setup.service.xml index ad674204258..ee061d39674 100644 --- a/man/systemd-keyring-setup.service.xml +++ b/man/systemd-keyring-setup.service.xml @@ -113,8 +113,8 @@ OS/kernel-keyring/CONTEXT/x509/NAME-certificate.pem, and trust anchors from OS/trust-anchor-kernel-keyring/CONTEXT/x509/NAME-certificate.pem - below each load path. Both are enrolled into the same keyring. NAME may consist - of lowercase letters, digits, + below each load path. Both are enrolled into the same keyring, and only differ in how their usage is + checked, see below. NAME may consist of lowercase letters, digits, ., _ and -, other files are ignored. Each file must contain a single PEM-encoded certificate. A file that contains more than one certificate is ignored as a whole. CA certificates should be placed in separate files among the trust anchors instead. @@ -152,6 +152,13 @@ world-writable, and directories that are world-writable, are ignored. Once a directory owned by an unprivileged user has been entered, the lookup does not continue into files or directories owned by anyone else. + + As required by the VOA specification, the usage of each certificate is checked against its purpose. + An artifact verifier is ignored if it has an extended key usage extension that permits neither code signing + nor any extended key usage, or a key usage extension that does not permit digital signatures. A trust anchor + that is not a CA certificate is enrolled nonetheless, since self-signed certificates that act as their own + trust anchor are common, but a message is logged about it. Certificates with invalid extensions are + ignored. diff --git a/src/keyring-setup/keyring-setup.rs b/src/keyring-setup/keyring-setup.rs index e352cf55012..27a56b07dca 100644 --- a/src/keyring-setup/keyring-setup.rs +++ b/src/keyring-setup/keyring-setup.rs @@ -21,7 +21,7 @@ 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::voa::{self, Lookup, VoaMode}; use systemd_shared::x509::{self, Der, X509}; use systemd_shared::{creds, fd, json, sys}; use systemd_shared::{libcrypto_note, libkmod_note, log_openssl_errors, table_log_add_error}; @@ -227,6 +227,50 @@ impl Keyring { } } +/// As the VOA specification requires, usage of each certificate is checked against its purpose. An artifact +/// verifier whose extended key usage permits neither code signing nor any usage is not enrolled. A trust +/// anchor that is not a CA certificate is still enrolled and only logged about at notice level, since a +/// self-signed leaf certificate doubling as its own anchor is common. +fn check_usage(path: &CStr, x: &X509, mode: VoaMode) -> Result<()> { + let flags = x.extension_flags(); + if flags & sys::EXFLAG_INVALID != 0 { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EKEYREJECTED), + "'{}' carries an invalid X.509 extension, ignoring.", + display(path) + )); + } + + if mode == voa::VOA_MODE_ARTIFACT_VERIFIER { + if x.extended_key_usage() + .is_some_and(|u| u & (sys::XKU_CODE_SIGN | sys::XKU_ANYEKU) == 0) + { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EKEYREJECTED), + "'{}' is not a code signing certificate, ignoring.", + display(path) + )); + } + if x.key_usage().is_some_and(|u| u & sys::KU_DIGITAL_SIGNATURE == 0) { + return Err(log_warning_errno!( + SYNTHETIC_ERRNO(EKEYREJECTED), + "'{}' does not permit digital signatures, ignoring.", + display(path) + )); + } + } else if (flags & sys::EXFLAG_BCONS != 0 && flags & sys::EXFLAG_CA == 0) + || x.key_usage().is_some_and(|u| u & sys::KU_KEY_CERT_SIGN == 0) + { + // A trust anchor that is explicitly not a CA is common enough to only log a notice about + log_notice!( + "'{}' is not a CA certificate, but placed among the trust anchors.", + display(path) + ); + } + + Ok(()) +} + /// The kernel describes a certificate as ": ", with the subject key identifier or, lacking one, /// the serial number as hex. Returns the ": " part, separator included. fn certificate_description_suffix(x: &X509) -> Result { @@ -265,7 +309,7 @@ impl core::fmt::Display for Hex<'_> { } /// Returns true if the certificate was added, false if the same certificate was found before. -fn load_certificate(k: &mut Keyring, f: &ConfFile) -> Result { +fn load_certificate(k: &mut Keyring, f: &ConfFile, mode: VoaMode) -> Result { let path = f.original_path(); let text = f @@ -302,7 +346,8 @@ fn load_certificate(k: &mut Keyring, f: &ConfFile) -> Result { )); } - // The kernel takes DER + check_usage(path, &x, mode)?; + let Some(der) = x.to_der() else { return Err(log_openssl_errors!( LOG_WARNING, @@ -370,7 +415,7 @@ fn collect_certificates(k: &mut Keyring, root: BorrowedFd<'_>, os: &Strv) -> Res })?; for f in files.iter() { - match load_certificate(k, f) { + match load_certificate(k, f, mode) { Err(e @ Errno::ENOMEM) => return Err(e), Err(_) => k.data_error = true, Ok(_) => {} From f9c9576b79a503f5fd0419fb64c6993399c41c21 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:36:29 +0200 Subject: [PATCH 4/6] mkosi: install mkosi.crt into the VOA hierarchy of the test images Place the certificate the test images are signed with below /usr/share/voa//kernel-keyring/{dm-verity,fs-verity}/x509/ in the host image and in the initrd so systemd-keyring-setup enrolls it into the .dm-verity and .fs-verity keyrings at boot. The initrd mirrors the copy mkosi installs there for userspace verification. Add a test CA in keyring-setup-ca.{crt,key} and install its certificate among the trust anchors for .dm-verity so trust anchors are enrolled as well. keyring-setup-ca.py mints the certificates without a subject key identifier that the tests need for the serial number fallback. OpenSSL's x509 tool adds that extension to every certificate it signs so they can't be produced with it. systemd-keyring-setup.service isn't enabled by default but the integration tests rely on it provisioning the keyrings from the initrd. So enable it via the mkosi presets. machine_supports_verity_keyring() looked for the mkosi certificate anywhere in /proc/keys. Now that .fs-verity holds it as well that doesn't imply dm-verity signature support anymore. So only look at the keyrings dm-verity actually consults. With the service enabled, VMs booted with dm_verity.keyring_unsealed=1 come up with the .dm-verity keyring provisioned and sealed already and the manual provisioning in TEST-90 fails. So TEST-90 now looks at the state of the keyring first: - sealed at boot: the certificate is in place already, check that a self-signed stranger is refused - sealed empty: systemd-keyring-setup enrolled nothing, fail - left open although the kernel left it unsealed and systemd-keyring-setup is enabled: the service didn't provision it, fail - otherwise provision it by hand like before and skip when the kernel sealed it at boot Signed-off-by: Christian Brauner (Amutable) --- mkosi/keyring-setup-test-ca.crt | 11 ++++ mkosi/keyring-setup-test-ca.key | 5 ++ mkosi/keyring-setup-test-ca.py | 41 +++++++++++++++ mkosi/mkosi.finalize | 11 ++++ mkosi/mkosi.initrd.conf/mkosi.finalize | 15 ++++++ ...-90-RESTRICT-FSACCESS.dm-verity-keyring.sh | 50 +++++++++++++++---- test/units/util.sh | 5 +- 7 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 mkosi/keyring-setup-test-ca.crt create mode 100644 mkosi/keyring-setup-test-ca.key create mode 100644 mkosi/keyring-setup-test-ca.py create mode 100755 mkosi/mkosi.initrd.conf/mkosi.finalize diff --git a/mkosi/keyring-setup-test-ca.crt b/mkosi/keyring-setup-test-ca.crt new file mode 100644 index 00000000000..6c11c0dd7aa --- /dev/null +++ b/mkosi/keyring-setup-test-ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnDCCAUOgAwIBAgIURIGL6fLWIcO+/Ako2/UTNpYX3LUwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQa2V5cmluZy1zZXR1cC1jYTAgFw0yNjA5MDExOTI0MzZaGA8y +MTI2MDgwODE5MjQzNlowGzEZMBcGA1UEAwwQa2V5cmluZy1zZXR1cC1jYTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABIE9RbLZ1pLUETv15tD8Pu8M7VujIPsLyfbI +gnnnMt8DuCWBcYpskR7UaXz3KGw8mrHSCXYHcjNr1VH1jKX1u9ijYzBhMB0GA1Ud +DgQWBBSJJP2p6+SQKwQUKcskhuxFrHPYIDAfBgNVHSMEGDAWgBSJJP2p6+SQKwQU +KcskhuxFrHPYIDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwICBDAKBggq +hkjOPQQDAgNHADBEAiBbfas+St1VlhTqYskeDmT3ysCEP/Mbnmq9ailCP+7C1wIg +dU2k8OMygpbLZBJaBWHvkKTXHrvGjYHslqtu7hO4W0A= +-----END CERTIFICATE----- diff --git a/mkosi/keyring-setup-test-ca.key b/mkosi/keyring-setup-test-ca.key new file mode 100644 index 00000000000..f47bd46acdb --- /dev/null +++ b/mkosi/keyring-setup-test-ca.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3BJkI9JHC9ZZF2dO +C3y3uBEpZrLp6pPnYniJ1KoJx4ehRANCAASBPUWy2daS1BE79ebQ/D7vDO1boyD7 +C8n2yIJ55zLfA7glgXGKbJEe1Gl89yhsPJqx0gl2B3Iza9VR9Yyl9bvY +-----END PRIVATE KEY----- diff --git a/mkosi/keyring-setup-test-ca.py b/mkosi/keyring-setup-test-ca.py new file mode 100644 index 00000000000..b1420f8f46f --- /dev/null +++ b/mkosi/keyring-setup-test-ca.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-or-later +"""Mint the extension-less test certificates embedded in TEST-98-KEYRING-SETUP.unsealed.sh. + +The subtests for the serial-number fallback of certificate_description_suffix() need certificates without +a subjectKeyIdentifier extension. OpenSSL's x509 tool adds one to every certificate it signs, no +matter what, so they cannot be produced with it. python-cryptography only adds what is asked for. + +The issuing CA pair next to this script was created with + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ + -subj /CN=keyring-setup-ca -days 36500 \ + -keyout keyring-setup-test-ca.key -out keyring-setup-test-ca.crt +and is installed into the trust-anchor hierarchy of the test images by mkosi.finalize. After +rotating the pair, rerun this script and paste the output over the embedded blobs. +""" + +import pathlib + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +here = pathlib.Path(__file__).parent +ca_cert = x509.load_pem_x509_certificate((here / 'keyring-setup-test-ca.crt').read_bytes()) +ca_key = serialization.load_pem_private_key((here / 'keyring-setup-test-ca.key').read_bytes(), password=None) + +for cn, serial in (('noskid', 0x8112131415161718), ('noskid2', 0x11121314)): + key = ec.generate_private_key(ec.SECP256R1()) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(serial) + .not_valid_before(ca_cert.not_valid_before_utc) + .not_valid_after(ca_cert.not_valid_after_utc) + .sign(ca_key, hashes.SHA256()) + ) + print(f'# {cn}, serial {serial:#x}') + print(cert.public_bytes(serialization.Encoding.PEM).decode(), end='') diff --git a/mkosi/mkosi.finalize b/mkosi/mkosi.finalize index 8db3db1ccf3..87214b92309 100755 --- a/mkosi/mkosi.finalize +++ b/mkosi/mkosi.finalize @@ -16,3 +16,14 @@ fi # Used to sign artifacts verified by kernel platform keyring cp "$SRCDIR/mkosi/mkosi.crt" "$SRCDIR/mkosi/mkosi.key" "$BUILDROOT/usr/share/" + +# The same certificate in the VOA hierarchy, for systemd-keyring-setup to enroll into the .dm-verity and +# .fs-verity keyrings +# shellcheck source=/dev/null +id=$(. "$BUILDROOT/usr/lib/os-release" && echo "$ID") +for context in dm-verity fs-verity; do + install -D -m 0644 "$SRCDIR/mkosi/mkosi.crt" "$BUILDROOT/usr/share/voa/$id/kernel-keyring/$context/x509/mkosi-certificate.pem" +done +# A CA certificate among the trust anchors, so that those get enrolled as well +install -D -m 0644 "$SRCDIR/mkosi/keyring-setup-test-ca.crt" \ + "$BUILDROOT/usr/share/voa/$id/trust-anchor-kernel-keyring/dm-verity/x509/ca-certificate.pem" diff --git a/mkosi/mkosi.initrd.conf/mkosi.finalize b/mkosi/mkosi.initrd.conf/mkosi.finalize new file mode 100755 index 00000000000..5c226926067 --- /dev/null +++ b/mkosi/mkosi.initrd.conf/mkosi.finalize @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -e + +# The certificate mkosi installs for userspace verification, mirrored into the VOA hierarchy so that +# systemd-keyring-setup enrolls it into the .dm-verity and .fs-verity keyrings while the initrd still can. +if [ -f "$BUILDROOT/usr/lib/verity.d/mkosi.crt" ]; then + # shellcheck source=/dev/null + id=$(. "$BUILDROOT/usr/lib/os-release" && echo "$ID") + for context in dm-verity fs-verity; do + install -D -m 0644 "$BUILDROOT/usr/lib/verity.d/mkosi.crt" "$BUILDROOT/usr/share/voa/$id/kernel-keyring/$context/x509/mkosi-certificate.pem" + done + install -D -m 0644 "$SRCDIR/mkosi/keyring-setup-test-ca.crt" \ + "$BUILDROOT/usr/share/voa/$id/trust-anchor-kernel-keyring/dm-verity/x509/ca-certificate.pem" +fi diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh b/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh index 5954512a553..2c9a1bba06c 100755 --- a/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh +++ b/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh @@ -57,20 +57,50 @@ if [[ "$val" != "Y" && "$val" != "1" ]]; then exit 77 fi -# Provision the .dm-verity keyring. Empty description lets the kernel derive -# one from the X.509 subject so machine_supports_verity_keyring finds the CN. -keyid=$(openssl x509 -in /usr/share/mkosi.crt -outform DER | - keyctl padd asymmetric '' %:.dm-verity 2>/dev/null) || keyid="" -if [[ -z "$keyid" ]]; then - echo ".dm-verity keyring not provisionable (kernel < v7.0?), skipping" +if ! grep -E ' keyring +\.dm-verity: ' /proc/keys >/dev/null; then + echo ".dm-verity keyring not available (kernel < v7.0?), skipping" exit 77 fi -if ! keyctl restrict_keyring %:.dm-verity; then - keyctl unlink "$keyid" %:.dm-verity 2>/dev/null || true - echo "ERROR: keyctl restrict_keyring failed" >&2 + +# systemd-keyring-setup.service provisions the keyring at boot and seals it +# (permission mask 08070000), after which only certificates signed by an +# enrolled key can be added and the certificate must already be in place. +# Provision by hand only otherwise. +if grep -E '^[0-9a-f]+ [^ ]+ +[0-9]+ +[^ ]+ +08070000 +[0-9]+ +[0-9]+ +keyring +\.dm-verity: [0-9]+$' /proc/keys >/dev/null; then + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=stranger -days 1 \ + -keyout /dev/null -out /tmp/stranger.pem + openssl x509 -in /tmp/stranger.pem -outform DER -out /tmp/stranger.der + if keyctl padd asymmetric '' %:.dm-verity /dev/null; then + echo "ERROR: sealed .dm-verity keyring accepted a stranger" >&2 + exit 1 + fi + echo ".dm-verity keyring already provisioned and sealed at boot" +elif grep -E ' +08070000 +[0-9]+ +[0-9]+ +keyring +\.dm-verity: empty$' /proc/keys >/dev/null; then + echo "ERROR: .dm-verity keyring sealed empty, systemd-keyring-setup enrolled nothing" >&2 exit 1 +else + # With the keyring left open by the kernel and systemd-keyring-setup enabled, + # getting here means the service did not provision it + if systemctl -q is-enabled systemd-keyring-setup.service 2>/dev/null && + [[ "$(cat /sys/module/dm_verity/parameters/keyring_unsealed 2>/dev/null)" == "Y" ]]; then + echo "ERROR: .dm-verity keyring left open, systemd-keyring-setup did not provision it" >&2 + exit 1 + fi + # Empty description lets the kernel derive one from the X.509 subject so + # machine_supports_verity_keyring finds the CN. + openssl x509 -in /usr/share/mkosi.crt -outform DER -out /tmp/mkosi.der + keyid=$(keyctl padd asymmetric '' %:.dm-verity /dev/null) || keyid="" + if [[ -z "$keyid" ]]; then + echo ".dm-verity keyring sealed by the kernel at boot (no dm_verity.keyring_unsealed=1), skipping" + exit 77 + fi + if ! keyctl restrict_keyring %:.dm-verity; then + keyctl unlink "$keyid" %:.dm-verity 2>/dev/null || true + echo "ERROR: keyctl restrict_keyring failed" >&2 + exit 1 + fi + echo "Provisioned .dm-verity keyring with mkosi.crt" fi -echo "Provisioned .dm-verity keyring with mkosi.crt" at_exit() { set +e diff --git a/test/units/util.sh b/test/units/util.sh index 2556f34af47..b26bf7d73f4 100755 --- a/test/units/util.sh +++ b/test/units/util.sh @@ -306,7 +306,10 @@ kernel_supports_lsm() { machine_supports_verity_keyring() { # Requires kernel built with certain kconfigs, as listed in README: # https://oracle.github.io/kconfigs/?config=UTS_RELEASE&config=DM_VERITY_VERIFY_ROOTHASH_SIG&config=DM_VERITY_VERIFY_ROOTHASH_SIG_SECONDARY_KEYRING&config=DM_VERITY_VERIFY_ROOTHASH_SIG_PLATFORM_KEYRING&config=IMA_ARCH_POLICY&config=INTEGRITY_MACHINE_KEYRING - if grep -q "$(openssl x509 -noout -subject -in /usr/share/mkosi.crt | sed 's/^.*CN=//')" /proc/keys && \ + # Only the keyrings dm-verity consults count, .fs-verity holds the same certificate without implying + # verity signature support + if { keyctl list %:.dm-verity; keyctl list %:.platform; keyctl list %:.machine; keyctl list %:.secondary_trusted_keys; :; } 2>/dev/null | + grep -F "$(openssl x509 -noout -subject -in /usr/share/mkosi.crt | sed 's/^.*CN=//')" >/dev/null && \ ( . /etc/os-release; [ "$ID" != "centos" ] || systemd-analyze compare-versions "$VERSION_ID" ge 10 ) && \ ( . /etc/os-release; [ "$ID" != "debian" ] || [ -z "${VERSION_ID:-}" ] || systemd-analyze compare-versions "$VERSION_ID" ge 13 ) && \ ( . /etc/os-release; [ "$ID" != "ubuntu" ] || systemd-analyze compare-versions "$VERSION_ID" ge 24.04 ) && \ From c948d10a6c155e16c572b04c266d7e8be387e074 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Wed, 26 Aug 2026 18:40:07 +0200 Subject: [PATCH 5/6] test: add TEST-98-KEYRING-SETUP Add an integration test for systemd-keyring-setup. It boots in two variants, both through an initrd where the service runs first: - Without dm_verity.keyring_unsealed=1 the kernel seals .dm-verity empty and the service has to leave it alone. .fs-verity is provisioned and sealed by the service. - With dm_verity.keyring_unsealed=1 and dm_verity.require_signatures=1 the service provisions .dm-verity from the VOA hierarchy and a credential, and the kernel verifies the signed test image against it. The subtests cover: - command line validation and the usage check against the VOA purpose - the sealed and unsealed keyring states and a second run changing nothing - certificates signed by an enrolled key being accepted later while others are refused, also as credentials - the highest-priority load path winning on colliding identifiers and byte-identical copies counting once - the serial number fallback for certificates without a subject key identifier - key material writable by others and files with more than one certificate being refused - credentials with rejected names, keyring-setup.os, private keys staying out of the hierarchy and credentials without a certificate - the kernel authenticating the image with the userspace fallback masked The subtests skip themselves when the program isn't built, i.e. without Rust support. Signed-off-by: Christian Brauner (Amutable) --- .../keyring-setup.dm-verity.extra | 11 + .../TEST-98-KEYRING-SETUP/meson.build | 36 +++ test/integration-tests/meson.build | 1 + test/units/TEST-98-KEYRING-SETUP.fs-verity.sh | 121 ++++++++++ test/units/TEST-98-KEYRING-SETUP.sealed.sh | 97 ++++++++ test/units/TEST-98-KEYRING-SETUP.sh | 9 + test/units/TEST-98-KEYRING-SETUP.unsealed.sh | 207 ++++++++++++++++++ 7 files changed, 482 insertions(+) create mode 100644 test/integration-tests/TEST-98-KEYRING-SETUP/keyring-setup.dm-verity.extra create mode 100644 test/integration-tests/TEST-98-KEYRING-SETUP/meson.build create mode 100755 test/units/TEST-98-KEYRING-SETUP.fs-verity.sh create mode 100755 test/units/TEST-98-KEYRING-SETUP.sealed.sh create mode 100755 test/units/TEST-98-KEYRING-SETUP.sh create mode 100755 test/units/TEST-98-KEYRING-SETUP.unsealed.sh diff --git a/test/integration-tests/TEST-98-KEYRING-SETUP/keyring-setup.dm-verity.extra b/test/integration-tests/TEST-98-KEYRING-SETUP/keyring-setup.dm-verity.extra new file mode 100644 index 00000000000..3526e337bd1 --- /dev/null +++ b/test/integration-tests/TEST-98-KEYRING-SETUP/keyring-setup.dm-verity.extra @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBkzCCATmgAwIBAgIUB1iKsODqTZ1IYTA+/glv7DzSkPgwCgYIKoZIzj0EAwIw +HjEcMBoGA1UEAwwTa2V5cmluZy1zZXR1cC1leHRyYTAgFw0yNjA4MjgxMjM2MDVa +GA8yMTI2MDgwNDEyMzYwNVowHjEcMBoGA1UEAwwTa2V5cmluZy1zZXR1cC1leHRy +YTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOOeWwYTrf2uIUeUBTSvorTIYhwI +zfn12j9HHwaQ8fTC+ZezLp6LIQoTZRk6dPyzZ9mYOcNVdvMdWq+c2oEUq32jUzBR +MB0GA1UdDgQWBBSY71cv1pViqLGu0C8Kn4+Uyg0KQDAfBgNVHSMEGDAWgBSY71cv +1pViqLGu0C8Kn4+Uyg0KQDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA +MEUCIQCNDKa0r7Omk8sUfVCtxhd1KADE0jluRr0N81//h/5a5AIgCCJRcwS72mkS +nVEUTbIWQfDVNIJ52tSPbu/GlgENhb4= +-----END CERTIFICATE----- diff --git a/test/integration-tests/TEST-98-KEYRING-SETUP/meson.build b/test/integration-tests/TEST-98-KEYRING-SETUP/meson.build new file mode 100644 index 00000000000..629059fa189 --- /dev/null +++ b/test/integration-tests/TEST-98-KEYRING-SETUP/meson.build @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# Without dm_verity.keyring_unsealed=1 the kernel seals .dm-verity empty and the +# service must leave it alone. .fs-verity is provisioned and sealed by the service. +# Both variants boot through an initrd, where the service runs first. +integration_tests += [ + integration_test_template + { + 'name' : fs.name(meson.current_source_dir()), + 'vm' : true, + 'firmware' : 'linux', + 'configuration' : integration_test_template['configuration'] + { + 'env' : {'TEST_MATCH_SUBTEST' : '[.](sealed|fs-verity)[.]sh$'}, + }, + }, +] + +# With the flag the service provisions .dm-verity from the VOA hierarchy at +# boot, and the kernel verifies the signed test image against it. +integration_tests += [ + integration_test_template + { + 'name' : fs.name(meson.current_source_dir()) + '-unsealed', + 'vm' : true, + 'firmware' : 'linux', + 'credentials' : integration_test_template['credentials'] + [ + files('keyring-setup.dm-verity.extra'), + ], + 'cmdline' : integration_test_template['cmdline'] + [ + 'dm_verity.keyring_unsealed=1', + 'dm_verity.require_signatures=1', + ], + 'configuration' : integration_test_template['configuration'] + { + 'command' : '/usr/lib/systemd/tests/testdata/units/' + fs.name(meson.current_source_dir()) + '.sh', + 'env' : {'TEST_MATCH_SUBTEST' : 'unsealed'}, + }, + }, +] diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build index 7d64b60ab77..b6b0d218ff0 100644 --- a/test/integration-tests/meson.build +++ b/test/integration-tests/meson.build @@ -115,6 +115,7 @@ foreach dirname : [ 'TEST-95-DBUS-RECONNECT', 'TEST-96-PTYBROKER', 'TEST-97-VMSPAWN-MEMORY', + 'TEST-98-KEYRING-SETUP', ] subdir(dirname) endforeach diff --git a/test/units/TEST-98-KEYRING-SETUP.fs-verity.sh b/test/units/TEST-98-KEYRING-SETUP.fs-verity.sh new file mode 100755 index 00000000000..fd5d411af15 --- /dev/null +++ b/test/units/TEST-98-KEYRING-SETUP.fs-verity.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Note that unlike the .dm-verity and .bpf keyrings the kernel never seals +# .fs-verity. Only systemd-keyring-setup does. It enrolled the certificate from +# the VOA hierarchy at boot and sealed the keyring like the others, so only +# certificates chaining to it can be added later, also via a credential. +set -eux +set -o pipefail + +KEYRING_SETUP=/usr/lib/systemd/systemd-keyring-setup + +if [[ ! -x "$KEYRING_SETUP" ]]; then + echo "systemd-keyring-setup is not built (no Rust support), skipping" + exit 77 +fi + +# Everything up to the keyring gate below needs no kernel keyring support +[[ "$(systemctl show -P ActiveState systemd-keyring-setup.service)" == "active" ]] +[[ "$(systemctl show -P Result systemd-keyring-setup.service)" == "success" ]] +[[ "$(systemctl show -P ExecMainStatus systemd-keyring-setup.service)" == "0" ]] + +# Rejected credential names are ignored and do not fail the run +systemd-run --pipe --wait \ + --property LoadCredential=keyring-setup.fs-verityX.foo:/usr/share/mkosi.crt \ + --property LoadCredential=keyring-setup.fs-verity.Bad:/usr/share/mkosi.crt \ + --property LoadCredential=keyring-setup.bogus.foo:/usr/share/mkosi.crt \ + "$KEYRING_SETUP" .fs-verity 2>&1 | tee /tmp/keyring-setup-creds.log +grep -F "Ignoring unrecognized credential 'keyring-setup.fs-verityX.foo'" /tmp/keyring-setup-creds.log >/dev/null +grep -F "Ignoring unrecognized credential 'keyring-setup.bogus.foo'" /tmp/keyring-setup-creds.log >/dev/null +grep -F "Ignoring credential 'keyring-setup.fs-verity.Bad'" /tmp/keyring-setup-creds.log >/dev/null +(! find /run/voa \( -name 'foo-certificate.pem' -o -name 'Bad-certificate.pem' \) | grep . >/dev/null) + +# keyring-setup.os overrides where credentials are placed, an invalid identifier is bad input +systemd-run --pipe --wait \ + --property SetCredential=keyring-setup.os:testos \ + --property LoadCredential=keyring-setup.fs-verity.osname:/usr/share/mkosi.crt \ + "$KEYRING_SETUP" .fs-verity +ls /run/voa/testos/kernel-keyring/fs-verity/x509/osname-certificate.pem +rc=0 +systemd-run --pipe --wait --property SetCredential=keyring-setup.os:INVALID "$KEYRING_SETUP" .fs-verity || rc=$? +[[ "$rc" -eq 65 ]] + +# Only the certificate of a credential is placed, a private key that came along stays out of the +# world-readable hierarchy. A credential without a certificate is bad input. +cat /usr/share/mkosi.key /usr/share/mkosi.crt >/tmp/withkey.pem +systemd-run --pipe --wait \ + --property SetCredential=keyring-setup.os:testos \ + --property LoadCredential=keyring-setup.fs-verity.withkey:/tmp/withkey.pem \ + "$KEYRING_SETUP" .fs-verity +(! grep -F "PRIVATE KEY" /run/voa/testos/kernel-keyring/fs-verity/x509/withkey-certificate.pem >/dev/null) +cmp <(openssl x509 -in /run/voa/testos/kernel-keyring/fs-verity/x509/withkey-certificate.pem -outform DER) \ + <(openssl x509 -in /usr/share/mkosi.crt -outform DER) +rm -f /tmp/withkey.pem +rc=0 +systemd-run --pipe --wait \ + --property SetCredential=keyring-setup.os:testos \ + --property LoadCredential=keyring-setup.fs-verity.nocert:/usr/share/mkosi.key \ + "$KEYRING_SETUP" .fs-verity || rc=$? +[[ "$rc" -eq 65 ]] +[[ ! -e /run/voa/testos/kernel-keyring/fs-verity/x509/nocert-certificate.pem ]] + +# Multiple identifiers feed the lookup, not only the placement of credentials. A distinct +# certificate, the byte-identical dedup would hide a copy of an already collected one. +mkdir -p /usr/share/voa/zweitos/kernel-keyring/fs-verity/x509 +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=zwei -days 1 \ + -keyout /dev/null -out /usr/share/voa/zweitos/kernel-keyring/fs-verity/x509/zwei-certificate.pem +systemd-run --pipe --wait \ + --property SetCredential="keyring-setup.os:zweitos testos" \ + "$KEYRING_SETUP" --dry-run --no-pager --json=short .fs-verity | tee /tmp/keyring-setup-multi.json +jq -e '.[0].certificates | any(contains("/zweitos/")) and any(contains("/testos/"))' /tmp/keyring-setup-multi.json +rm -rf /usr/share/voa/zweitos + +# An empty identifier list and one longer than 64 entries are bad input +rc=0 +systemd-run --pipe --wait --property SetCredential=keyring-setup.os: \ + "$KEYRING_SETUP" .fs-verity 2>&1 | tee /tmp/keyring-setup-os-empty.log || rc=$? +[[ "$rc" -eq 65 ]] +grep -F "Credential keyring-setup.os is empty, ignoring." /tmp/keyring-setup-os-empty.log >/dev/null +rc=0 +systemd-run --pipe --wait --property SetCredential="keyring-setup.os:$(seq -f 'os%.0f' 65 | tr '\n' ' ')" \ + "$KEYRING_SETUP" .fs-verity 2>&1 | tee /tmp/keyring-setup-os-many.log || rc=$? +[[ "$rc" -eq 65 ]] +grep -F "lists more than 64 OS identifiers, ignoring it." /tmp/keyring-setup-os-many.log >/dev/null + +# An os-release ID= outside of the VOA charset disables the lookup, reported once, the run still fails +printf 'ID=Debian\n' >/tmp/bad-os-release +rc=0 +systemd-run --pipe --wait --property BindReadOnlyPaths=/tmp/bad-os-release:/etc/os-release \ + "$KEYRING_SETUP" .fs-verity 2>&1 | tee /tmp/keyring-setup-badid.log || rc=$? +[[ "$rc" -eq 1 ]] +grep -F "Failed to determine the OS identifier from os-release, enrolling nothing" /tmp/keyring-setup-badid.log >/dev/null +(! grep -F "Failed to enumerate certificates" /tmp/keyring-setup-badid.log >/dev/null) + +if ! grep -E ' keyring +\.fs-verity: ' /proc/keys >/dev/null; then + echo ".fs-verity keyring not available (CONFIG_FS_VERITY_BUILTIN_SIGNATURES?), skipping" + exit 77 +fi + +# Sealed, holding the mkosi certificate +grep -E '^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +08070000 +0 +0 +keyring +\.fs-verity: 1$' /proc/keys +CN="$(openssl x509 -noout -subject -in /usr/share/mkosi.crt | sed 's/^.*CN *= *//')" +grep -E "^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +39010000 +0 +0 +asymmetri .*$CN" /proc/keys +. /etc/os-release +# Not -u: messages of the initrd run carry no _SYSTEMD_UNIT=, see the unsealed subtest +journalctl --merge -o cat -t systemd-keyring-setup | + grep -F "Enrolled '/usr/share/voa/$ID/kernel-keyring/fs-verity/x509/mkosi-certificate.pem' into keyring .fs-verity" >/dev/null +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=stranger -days 1 \ + -keyout /dev/null -out /tmp/stranger.pem +openssl x509 -in /tmp/stranger.pem -outform DER -out /tmp/stranger.der +(! keyctl padd asymmetric '' %:.fs-verity &1 | tee /tmp/keyring-setup-late.log +(! grep -F "Enrolled '" /tmp/keyring-setup-late.log >/dev/null) +ls /run/voa/*/kernel-keyring/fs-verity/x509/late-certificate.pem +grep -E ' +08070000 +0 +0 +keyring +\.fs-verity: 1$' /proc/keys diff --git a/test/units/TEST-98-KEYRING-SETUP.sealed.sh b/test/units/TEST-98-KEYRING-SETUP.sealed.sh new file mode 100755 index 00000000000..bdf9434b8f9 --- /dev/null +++ b/test/units/TEST-98-KEYRING-SETUP.sealed.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Booted without dm_verity.keyring_unsealed=1: the kernel sealed .dm-verity +# empty, systemd-keyring-setup finds the certificates but can enroll nothing, +# and must leave the keyring alone. +set -eux +set -o pipefail + +KEYRING_SETUP=/usr/lib/systemd/systemd-keyring-setup + +if [[ ! -x "$KEYRING_SETUP" ]]; then + echo "systemd-keyring-setup is not built (no Rust support), skipping" + exit 77 +fi + +# Everything up to the keyring gate below needs no kernel keyring support +[[ "$(systemctl show -P ActiveState systemd-keyring-setup.service)" == "active" ]] +[[ "$(systemctl show -P Result systemd-keyring-setup.service)" == "success" ]] +[[ "$(systemctl show -P ExecMainStatus systemd-keyring-setup.service)" == "0" ]] + +# An unknown keyring, a name without its leading dot, and --json= without --dry-run are rejected +(! "$KEYRING_SETUP" --dry-run .bogus) +(! "$KEYRING_SETUP" --dry-run dm-verity) +(! "$KEYRING_SETUP" --json=short .dm-verity) + +# The usage check keeps a certificate that cannot sign code out and reports it as bad input, while +# one permitting any usage and a CA certificate placed among the trust anchors are accepted +. /etc/os-release +mkdir -p "/etc/voa/$ID/kernel-keyring/dm-verity/x509" "/etc/voa/$ID/trust-anchor-kernel-keyring/dm-verity/x509" +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=tls -days 1 \ + -addext extendedKeyUsage=serverAuth \ + -keyout /dev/null -out "/etc/voa/$ID/kernel-keyring/dm-verity/x509/tls-certificate.pem" +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=anyeku -days 1 \ + -addext extendedKeyUsage=critical,anyExtendedKeyUsage \ + -keyout /dev/null -out "/etc/voa/$ID/kernel-keyring/dm-verity/x509/anyeku-certificate.pem" +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=ca -days 1 \ + -addext basicConstraints=critical,CA:TRUE -addext keyUsage=critical,keyCertSign \ + -keyout /dev/null -out "/etc/voa/$ID/trust-anchor-kernel-keyring/dm-verity/x509/ca-certificate.pem" +:>"/etc/voa/$ID/kernel-keyring/dm-verity/x509/empty-certificate.pem" +truncate -s 2M "/etc/voa/$ID/kernel-keyring/dm-verity/x509/large-certificate.pem" +rc=0 +"$KEYRING_SETUP" --dry-run --no-pager --json=short .dm-verity >/tmp/keyring-setup.json || rc=$? +[[ "$rc" -eq 65 ]] +jq -e '.[0].certificates | any(endswith("/mkosi-certificate.pem")) and any(endswith("/anyeku-certificate.pem")) + and any(endswith("/trust-anchor-kernel-keyring/dm-verity/x509/ca-certificate.pem")) + and (any(endswith("/tls-certificate.pem")) | not) + and (any(endswith("/empty-certificate.pem")) | not) + and (any(endswith("/large-certificate.pem")) | not)' /tmp/keyring-setup.json + +# The .bpf spec looks up the bpf role +mkdir -p "/etc/voa/$ID/kernel-keyring/bpf/x509" +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=bpfrole -days 1 \ + -keyout /dev/null -out "/etc/voa/$ID/kernel-keyring/bpf/x509/bpfrole-certificate.pem" +"$KEYRING_SETUP" --dry-run --no-pager --json=short .bpf >/tmp/keyring-setup-bpf.json +jq -e '.[0].certificates | any(endswith("/kernel-keyring/bpf/x509/bpfrole-certificate.pem"))' /tmp/keyring-setup-bpf.json +# .fs-verity has no keyring_unsealed parameter, its kernel_unsealed state is null +"$KEYRING_SETUP" --dry-run --no-pager --json=short .fs-verity | jq -e '.[0].kernel_unsealed == null' +rm -rf "/etc/voa/$ID" /tmp/keyring-setup.json /tmp/keyring-setup-bpf.json + +if ! grep -E ' keyring +\.dm-verity: ' /proc/keys >/dev/null; then + if ! modprobe dm_verity 2>/dev/null || ! grep -E ' keyring +\.dm-verity: ' /proc/keys >/dev/null; then + echo ".dm-verity keyring not available (kernel < v7.0?), skipping" + exit 77 + fi + # The service loads dm_verity itself, the keyring must have existed when it ran + echo "ERROR: dm_verity was not loaded when systemd-keyring-setup.service ran" >&2 + exit 1 +fi + +[[ "$(cat /sys/module/dm_verity/parameters/keyring_unsealed)" == "N" ]] + +# The initial permission mask is untouched and the keyring stays empty +grep -E '^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +082f0000 +0 +0 +keyring +\.dm-verity: empty$' /proc/keys + +# The certificates were found, mkosi.crt among them +"$KEYRING_SETUP" --dry-run --no-pager --json=short .dm-verity | + jq -e '.[0] | .exists == true and .kernel_unsealed == false and .sealed == false and (.certificates | any(endswith("/mkosi-certificate.pem")))' + +# Running again changes nothing +"$KEYRING_SETUP" .dm-verity +grep -E ' +082f0000 +0 +0 +keyring +\.dm-verity: empty$' /proc/keys + +# Without a key in the .dm-verity keyring the kernel cannot verify the signed +# test image, so with systemd's userspace fallback masked the activation must +# fail, and succeed once the fallback is available again. +mkdir -p /etc/verity.d +ln -sf /dev/null /etc/verity.d/mkosi.crt +(! systemd-run --pipe --wait \ + --property RootImage=/usr/share/minimal_0.raw \ + --property RootImagePolicy=root=signed \ + bash --version >/dev/null) +rm -f /etc/verity.d/mkosi.crt +systemd-run --pipe --wait \ + --property RootImage=/usr/share/minimal_0.raw \ + --property RootImagePolicy=root=signed \ + bash --version >/dev/null diff --git a/test/units/TEST-98-KEYRING-SETUP.sh b/test/units/TEST-98-KEYRING-SETUP.sh new file mode 100755 index 00000000000..a61a7655894 --- /dev/null +++ b/test/units/TEST-98-KEYRING-SETUP.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eux +set -o pipefail + +# shellcheck source=test/units/test-control.sh +. "$(dirname "$0")"/test-control.sh + +run_subtests_and_exit diff --git a/test/units/TEST-98-KEYRING-SETUP.unsealed.sh b/test/units/TEST-98-KEYRING-SETUP.unsealed.sh new file mode 100755 index 00000000000..539bbb5124c --- /dev/null +++ b/test/units/TEST-98-KEYRING-SETUP.unsealed.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Booted with dm_verity.keyring_unsealed=1: systemd-keyring-setup enrolled the +# certificates from the VOA hierarchy at boot, sealed the keyring, and the +# kernel verifies the signed test image against it. +set -eux +set -o pipefail + +KEYRING_SETUP=/usr/lib/systemd/systemd-keyring-setup + +if [[ ! -x "$KEYRING_SETUP" ]]; then + echo "systemd-keyring-setup is not built (no Rust support), skipping" + exit 77 +fi + +if ! grep -E ' keyring +\.dm-verity: ' /proc/keys >/dev/null; then + echo ".dm-verity keyring not available (kernel < v7.0?), skipping" + exit 77 +fi + +[[ "$(systemctl show -P ActiveState systemd-keyring-setup.service)" == "active" ]] +[[ "$(systemctl show -P Result systemd-keyring-setup.service)" == "success" ]] +[[ "$(systemctl show -P ExecMainStatus systemd-keyring-setup.service)" == "0" ]] +[[ "$(cat /sys/module/dm_verity/parameters/keyring_unsealed)" == "Y" ]] + +# Sealed, holding the mkosi certificate under its kernel-derived description, the one handed +# over as a credential and the trust anchor +grep -E '^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +08070000 +0 +0 +keyring +\.dm-verity: 3$' /proc/keys +CN="$(openssl x509 -noout -subject -in /usr/share/mkosi.crt | sed 's/^.*CN *= *//')" +grep -E "^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +39010000 +0 +0 +asymmetri .*$CN" /proc/keys +grep -E '^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +39010000 +0 +0 +asymmetri .*keyring-setup-extra' /proc/keys +grep -E '^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +39010000 +0 +0 +asymmetri .*keyring-setup-ca' /proc/keys +# /proc/keys does not say which keyring a key is linked into, check that they are members of .dm-verity +keyctl list %:.dm-verity | grep -F "$CN" +keyctl list %:.dm-verity | grep -F keyring-setup-extra +keyctl list %:.dm-verity | grep -F keyring-setup-ca +"$KEYRING_SETUP" --dry-run --no-pager --json=short .dm-verity | jq -e '.[0].kernel_unsealed == true' + +# The service enrolled them from the VOA hierarchy in the initrd, before the switch to the host +. /etc/os-release +# Not -u: the initrd run has exited by the time journald reads its messages, so they carry no _SYSTEMD_UNIT= +journalctl --merge -o json -t systemd-keyring-setup >/tmp/keyring-setup.journal.json +enrolled="$(jq -rn --arg m "Enrolled '/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem' into keyring .dm-verity" \ + 'first(inputs | select(.MESSAGE | startswith($m)) | .__MONOTONIC_TIMESTAMP)' /tmp/keyring-setup.journal.json)" +journalctl --merge -o json _PID=1 >/tmp/pid1.journal.json +switched="$(jq -rn 'first(inputs | select(.MESSAGE | startswith("Switching root")) | .__MONOTONIC_TIMESTAMP)' /tmp/pid1.journal.json)" +rm -f /tmp/keyring-setup.journal.json /tmp/pid1.journal.json +[[ -n "$enrolled" && -n "$switched" && "$enrolled" -lt "$switched" ]] +journalctl --merge -o cat -t systemd-keyring-setup | + grep -E "^Enrolled '/run/voa/[^/]+/kernel-keyring/dm-verity/x509/extra-certificate.pem' into keyring .dm-verity" >/dev/null +journalctl --merge -o cat -t systemd-keyring-setup | + grep -F "Enrolled '/usr/share/voa/$ID/trust-anchor-kernel-keyring/dm-verity/x509/ca-certificate.pem' into keyring .dm-verity" >/dev/null + +# From now on, only certificates signed by an enrolled key can be added, and neither the mask nor the +# restriction can be changed, not even by root +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=stranger -days 1 \ + -keyout /dev/null -out /tmp/stranger.pem +openssl x509 -in /tmp/stranger.pem -outform DER -out /tmp/stranger.der +(! keyctl padd asymmetric '' %:.dm-verity &1 | tee /tmp/keyring-setup-again.log +(! grep -F "Enrolled '" /tmp/keyring-setup-again.log >/dev/null) +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 4$' /proc/keys + +# A certificate configured after the fact is refused and reported as bad input unless it is signed +# by an enrolled key +mkdir -p "/etc/voa/$ID/kernel-keyring/dm-verity/x509" +cp /tmp/stranger.pem "/etc/voa/$ID/kernel-keyring/dm-verity/x509/late-certificate.pem" +rc=0 +"$KEYRING_SETUP" .dm-verity || rc=$? +[[ "$rc" -eq 65 ]] +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 4$' /proc/keys +openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=late \ + -keyout /dev/null -out /tmp/late.csr +openssl x509 -req -in /tmp/late.csr -CA /usr/share/mkosi.crt -CAkey /usr/share/mkosi.key \ + -set_serial 2 -days 1 -extfile <(echo authorityKeyIdentifier=keyid,issuer) \ + -out "/etc/voa/$ID/kernel-keyring/dm-verity/x509/late-certificate.pem" +"$KEYRING_SETUP" .dm-verity +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 5$' /proc/keys +grep -E "^[0-9a-f]+ [A-Za-z-]{7} +[0-9]+ +perm +39010000 +0 +0 +asymmetri +late: [0-9a-f]+: " /proc/keys + +# On a colliding subject and key identifier the copy from the highest-priority load path wins +openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=dup \ + -keyout /tmp/dup.key -out /tmp/dup.csr +openssl x509 -req -in /tmp/dup.csr -CA /usr/share/mkosi.crt -CAkey /usr/share/mkosi.key \ + -set_serial 3 -days 1 \ + -extfile <(printf 'authorityKeyIdentifier=keyid,issuer\nsubjectKeyIdentifier=hash\n') \ + -out "/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem" +openssl x509 -req -in /tmp/dup.csr -CA /usr/share/mkosi.crt -CAkey /usr/share/mkosi.key \ + -set_serial 4 -days 2 \ + -extfile <(printf 'authorityKeyIdentifier=keyid,issuer\nsubjectKeyIdentifier=hash\n') \ + -out "/etc/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem" +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-dup.log +grep -F "Enrolled '/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem'" /tmp/keyring-setup-dup.log >/dev/null +grep -F "Enrolled '/etc/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem'" /tmp/keyring-setup-dup.log >/dev/null +grep -F "'/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem' was superseded by a certificate of higher priority" /tmp/keyring-setup-dup.log >/dev/null +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 6$' /proc/keys +rm -f "/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/dup-certificate.pem" + +# The same certificate in two load paths is enrolled once, neither copy supersedes the other +cp "/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem" \ + "/etc/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem" +SYSTEMD_LOG_LEVEL=debug "$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-samecert.log +grep -F "'/usr/share/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem' is identical to '/etc/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem', skipping." /tmp/keyring-setup-samecert.log >/dev/null +(! grep -F "Enrolled '" /tmp/keyring-setup-samecert.log >/dev/null) +(! grep -F "was superseded" /tmp/keyring-setup-samecert.log >/dev/null) +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 6$' /proc/keys +rm -f "/etc/voa/$ID/kernel-keyring/dm-verity/x509/mkosi-certificate.pem" + +# Without a subject key identifier the serial number, DER padding included, identifies the key: +# CN=noskid, serial 0x8112131415161718, no extensions, signed by the committed keyring-setup-test-ca key, +# minted by mkosi/keyring-setup-test-ca.py +cat >"/etc/voa/$ID/kernel-keyring/dm-verity/x509/noskid-certificate.pem" <<'CERT' +-----BEGIN CERTIFICATE----- +MIIBIjCByaADAgECAgkAgRITFBUWFxgwCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQ +a2V5cmluZy1zZXR1cC1jYTAgFw0yNjA5MDExOTI0MzZaGA8yMTI2MDgwODE5MjQz +NlowETEPMA0GA1UEAwwGbm9za2lkMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +FRpKLUgmHK8vFB9/N5ZRV54Bwk8uOxU8tt1R1A3OMwgn/ct32PBBNj5HbiS/Wta2 +qs6KODUBlPVfCEXktGHMYzAKBggqhkjOPQQDAgNIADBFAiEAsd92t7WgIFHe+cp/ +1gjd7OXQw+wkW+Y33iixvkGLA/QCIBLVHP7dhYYkqkxZSkrcUQF805lE893LDnuF +MQVSUlGj +-----END CERTIFICATE----- +CERT +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-noskid.log +grep -F "Enrolled '/etc/voa/$ID/kernel-keyring/dm-verity/x509/noskid-certificate.pem' into keyring .dm-verity as noskid: 008112131415161718" /tmp/keyring-setup-noskid.log >/dev/null +keyctl list %:.dm-verity | grep -F 'noskid: 008112131415161718' +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 7$' /proc/keys + +# And the second run recognizes it by that identifier and enrolls nothing +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-noskid2.log +(! grep -F "Enrolled '/etc/voa/$ID/kernel-keyring/dm-verity/x509/noskid-certificate.pem'" /tmp/keyring-setup-noskid2.log >/dev/null) +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 7$' /proc/keys + +# A serial without the top bit set gets no zero pad: CN=noskid2, serial 0x11121314, same CA and script +cat >"/etc/voa/$ID/kernel-keyring/dm-verity/x509/noskid2-certificate.pem" <<'CERT' +-----BEGIN CERTIFICATE----- +MIIBHjCBxaADAgECAgQREhMUMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEGtleXJp +bmctc2V0dXAtY2EwIBcNMjYwOTAxMTkyNDM2WhgPMjEyNjA4MDgxOTI0MzZaMBIx +EDAOBgNVBAMMB25vc2tpZDIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQZeGsy +lGeEJ5rQtap+PKYtjPtqc3j84NM++obHFcfMjt+jhTRUUtoZxP7cYvODHLZiJqne +y5VXUwcozNcN7E3EMAoGCCqGSM49BAMCA0gAMEUCIQCjnDs9G6zA1fNGMhIRSnHV +f0WXbXciDE8wxo/p7owp7wIgGMLuK/KOyRkgKZLNZx9HG38xmgZYb73tcGFeqg2X +ihY= +-----END CERTIFICATE----- +CERT +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-noskid3.log +grep -F "Enrolled '/etc/voa/$ID/kernel-keyring/dm-verity/x509/noskid2-certificate.pem' into keyring .dm-verity as noskid2: 11121314" /tmp/keyring-setup-noskid3.log >/dev/null +keyctl list %:.dm-verity | grep -F 'noskid2: 11121314' +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 8$' /proc/keys + +# Key material writable by others is refused, once the mode is sane it is enrolled +openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=loose \ + -keyout /dev/null -out /tmp/loose.csr +openssl x509 -req -in /tmp/loose.csr -CA /usr/share/mkosi.crt -CAkey /usr/share/mkosi.key \ + -set_serial 5 -days 1 -extfile <(echo authorityKeyIdentifier=keyid,issuer) \ + -out "/etc/voa/$ID/kernel-keyring/dm-verity/x509/loose-certificate.pem" +chmod 0666 "/etc/voa/$ID/kernel-keyring/dm-verity/x509/loose-certificate.pem" +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-loose.log +grep -F "Failed to chase '/etc/voa/$ID/kernel-keyring/dm-verity/x509/loose-certificate.pem': Link has been severed" /tmp/keyring-setup-loose.log >/dev/null +(! keyctl list %:.dm-verity | grep -F 'loose' >/dev/null) +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 8$' /proc/keys +chmod 0644 "/etc/voa/$ID/kernel-keyring/dm-verity/x509/loose-certificate.pem" +"$KEYRING_SETUP" .dm-verity +keyctl list %:.dm-verity | grep -F 'loose' +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 9$' /proc/keys + +# A file holding more than one certificate is refused as a whole, intermediates cannot be smuggled in +for cn in chainfirst chainsecond; do + openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=$cn" \ + -keyout /dev/null -out "/tmp/$cn.csr" + openssl x509 -req -in "/tmp/$cn.csr" -CA /usr/share/mkosi.crt -CAkey /usr/share/mkosi.key \ + -set_serial "0x$(printf '%s' "$cn" | sha256sum | cut -c1-8)" -days 1 \ + -extfile <(echo authorityKeyIdentifier=keyid,issuer) -out "/tmp/$cn.pem" +done +cat /tmp/chainfirst.pem /tmp/chainsecond.pem >"/etc/voa/$ID/kernel-keyring/dm-verity/x509/chain-certificate.pem" +rc=0 +"$KEYRING_SETUP" .dm-verity 2>&1 | tee /tmp/keyring-setup-chain.log || rc=$? +[[ "$rc" -eq 65 ]] +grep -F "'/etc/voa/$ID/kernel-keyring/dm-verity/x509/chain-certificate.pem' contains more than one certificate, ignoring." /tmp/keyring-setup-chain.log >/dev/null +(! keyctl list %:.dm-verity | grep -F 'chainfirst' >/dev/null) +(! keyctl list %:.dm-verity | grep -F 'chainsecond' >/dev/null) +grep -E ' +08070000 +0 +0 +keyring +\.dm-verity: 9$' /proc/keys + +rm -rf "/etc/voa/$ID" + +# Check that the kernel verifies the image against the .dm-verity keyring: with the certificate masked +# for systemd's userspace fallback and a signed image required, activation only succeeds if the +# kernel accepted the signature. +mkdir -p /etc/verity.d +ln -sf /dev/null /etc/verity.d/mkosi.crt +systemd-run --pipe --wait \ + --property RootImage=/usr/share/minimal_0.raw \ + --property RootImagePolicy=root=signed \ + bash --version >/dev/null +rm -f /etc/verity.d/mkosi.crt From 36fedadf4f39e65638d748d261a1e62b841715d4 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 27 Aug 2026 09:32:13 +0200 Subject: [PATCH 6/6] docs: announce systemd-keyring-setup and its kernel requirements Add a NEWS entry for systemd-keyring-setup and list what it needs from the kernel in README. The .dm-verity keyring needs v7.0 and verifying signed verity images and fs-verity files with the certificates it enrolls needs CONFIG_KEYS and CONFIG_FS_VERITY_BUILTIN_SIGNATURES. Signed-off-by: Christian Brauner (Amutable) --- NEWS | 26 ++++++++++++++++++++++++++ README | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/NEWS b/NEWS index 401072382a8..b80997e9bf3 100644 --- a/NEWS +++ b/NEWS @@ -201,6 +201,32 @@ CHANGES WITH 263 in spe: "# [Experimental]" comment line in the interface description, which sd_varlink_idl_parse() turns back into the flag. + Changes in systemd-keyring-setup: + + * A new service systemd-keyring-setup.service has been added that + enrolls X.509 certificates into the kernel's .dm-verity, .fs-verity + and .bpf keyrings early during boot, and seals the keyrings + afterwards. The certificates are read from the File Hierarchy for + the Verification of OS Artifacts (VOA), i.e. from /etc/voa/, + /run/voa/, /usr/local/share/voa/ and /usr/share/voa/, or may be + passed in as keyring-setup.* system credentials. This allows the + kernel to verify signed dm-verity volumes, fs-verity files and BPF + programs with certificates provided by the OS vendor or the local + administrator, without rebuilding the kernel or enrolling keys into + the firmware. Note that the kernel seals the .dm-verity and .bpf + keyrings while they are still empty, unless + dm_verity.keyring_unsealed=1 or bpf.keyring_unsealed=1 is specified + on the kernel command line, respectively. The service is enabled + statically whenever it is built, and is considered experimental for + now. See + systemd-keyring-setup.service(8) and + https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/ + for details. + + systemd-keyring-setup is the first systemd component written in + Rust, and is hence only built if Rust support is enabled with the + new -Drust= build option. + CHANGES WITH 262: Announcements of Future Feature Removals and Incompatible Changes: diff --git a/README b/README index 81566028a34..f6b78406657 100644 --- a/README +++ b/README @@ -87,6 +87,7 @@ REQUIREMENTS: PIDFD_INFO_COREDUMP by ioctl(PIDFD_GET_INFO) ≥ 6.17 for coredump socket pattern ('@@' prefix) ≥ 6.19 for PIDFD_INFO_SUPPORTED_MASK and PIDFD_INFO_COREDUMP_SIGNAL + ≥ 7.0 for the .dm-verity keyring provisioned by systemd-keyring-setup ≥ 7.1 for PIDFD_INFO_COREDUMP_CODE ✅ systemd utilizes several new kernel APIs, but will fall back gracefully @@ -182,6 +183,10 @@ REQUIREMENTS: CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG_PLATFORM_KEYRING CONFIG_IMA_ARCH_POLICY CONFIG_INTEGRITY_MACHINE_KEYRING + Required to verify signed Verity images and fs-verity files with + certificates enrolled by systemd-keyring-setup: + CONFIG_KEYS + CONFIG_FS_VERITY_BUILTIN_SIGNATURES Required for reading credentials from SMBIOS: CONFIG_DMI