diff --git a/NEWS b/NEWS index 44b4bfeced3..ffc0d68fd86 100644 --- a/NEWS +++ b/NEWS @@ -209,6 +209,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 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..ee061d39674 --- /dev/null +++ b/man/systemd-keyring-setup.service.xml @@ -0,0 +1,300 @@ + + + + + + + + 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, 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. + + 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. 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 + 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. + + 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. + + + + 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 + + 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 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. + + + + 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 + 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/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/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/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..27a56b07dca --- /dev/null +++ b/src/keyring-setup/keyring-setup.rs @@ -0,0 +1,1218 @@ +// 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::{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, 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}; + +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; + +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. + 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(()) + } +} + +/// 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 { + 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, mode: VoaMode) -> 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) + )); + } + + check_usage(path, &x, mode)?; + + 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, mode) { + 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) +} + +/// 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!(), + 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; + + // 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() + } + } + } + }; + + // 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", + 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/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/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-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/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 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 ) && \ 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..7904cd4ce7a --- /dev/null +++ b/units/systemd-keyring-setup.service.in @@ -0,0 +1,55 @@ +# 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 +ImportCredential=keyring-setup.* +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 +# 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