keyring-setup: accept certificates via system credentials

Allow the use of credentials (SMBIOS, ESP credential drop-ins,
--set-credential for the boot loader) to ship keys to enroll into
keyrings. This lets users avoid having to ship keys as part of the
initrd.

So accept keyring-setup.<keyring>.<name> credentials holding one PEM
certificate each. We don't enroll them directly but follow the VOA spec
instead. Before enrolling they are placed into the ephemeral load path
as:

  /run/voa/<os>/kernel-keyring/<context>/x509/<name>-certificate.pem

This also makes masking, merging and deduplication apply to them like
to any other file. Only the certificate is written there, encoded anew,
so a private key that came along in the credential doesn't end up
world-readable. A credential that isn't a single certificate is bad
input.

A keyring-setup.os credential overrides the OS identifiers derived from
os-release. This is useful for generic initrds whose identity differs
from the host's.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
This commit is contained in:
Christian Brauner committed 2026-10-26 18:55:53 +01:00
1 parent 24548be0bf
commit b98913f4b8
5 files changed
+309 -18

No files matched your search

+50 -2
View File
@@ -129,7 +129,8 @@
<literal>debian:13</literal> and <literal>debian</literal>. If <varname>ID=</varname> is not set,
<literal>linux</literal> is used. If one of the other fields contains characters that are not permitted by
the VOA specification, only the value of <varname>ID=</varname> is used. If <varname>ID=</varname> itself
contains such characters, no certificates are looked up.</para>
contains such characters, no certificates are looked up. The <varname>keyring-setup.os</varname> credential
may be used to specify the identifiers explicitly, see below.</para>
<para>Note that the VOA specification requires different semantics than the usual logic for configuration
files, where a file in a directory with higher priority replaces a file with the same name in a directory
@@ -153,6 +154,52 @@
else.</para>
</refsect1>
<refsect1>
<title>Credentials</title>
<para><command>systemd-keyring-setup</command> supports the service credentials logic as implemented by
<varname>ImportCredential=</varname>/<varname>LoadCredential=</varname>/<varname>SetCredential=</varname>
(see <citerefentry><refentrytitle>systemd.exec</refentrytitle><manvolnum>5</manvolnum></citerefentry> for
details). The following credentials are used when passed in:</para>
<variablelist class='system-credentials'>
<varlistentry>
<term><varname>keyring-setup.dm-verity.<replaceable>NAME</replaceable></varname></term>
<term><varname>keyring-setup.fs-verity.<replaceable>NAME</replaceable></varname></term>
<term><varname>keyring-setup.bpf.<replaceable>NAME</replaceable></varname></term>
<listitem><para>A PEM-encoded X.509 certificate to enroll into the <literal>.dm-verity</literal>,
<literal>.fs-verity</literal> or <literal>.bpf</literal> keyring, respectively.
<replaceable>NAME</replaceable> may consist of lowercase letters, digits, <literal>.</literal>,
<literal>_</literal> and <literal>-</literal>. As suggested by the VOA specification for certificates
obtained from elsewhere, the certificate is not enrolled directly, but is first written to
<filename>/run/voa/<replaceable>OS</replaceable>/kernel-keyring/<replaceable>CONTEXT</replaceable>/x509/<replaceable>NAME</replaceable>-certificate.pem</filename>,
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.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>keyring-setup.os</varname></term>
<listitem><para>A whitespace-separated list of up to 64 OS identifiers to use instead of the ones
derived from <citerefentry><refentrytitle>os-release</refentrytitle><manvolnum>5</manvolnum></citerefentry>.
This is useful for generic initrds, whose <filename>os-release</filename> 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 <filename>os-release</filename> are used.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
</variablelist>
<para>See
<citerefentry><refentrytitle>systemd.system-credentials</refentrytitle><manvolnum>7</manvolnum></citerefentry>
for details about system credentials.</para>
</refsect1>
<refsect1>
<title>Options</title>
@@ -184,7 +231,7 @@
<title>Exit status</title>
<para>On success, 0 is returned. This includes the cases where a keyring does not exist, or has been
provisioned by an earlier run already. If a certificate could not be used, for example
provisioned by an earlier run already. If a certificate or a credential could not be used, for example
because it is malformed, or because it is not signed by a key in a keyring that has been sealed already, 65
(<constant>EX_DATAERR</constant>) is returned. <filename>systemd-keyring-setup.service</filename> treats this
exit status as success. On other failures, a different non-zero exit status is returned.</para>
@@ -237,6 +284,7 @@
<member><citerefentry><refentrytitle>systemd-veritysetup@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>systemd-dissect</refentrytitle><manvolnum>1</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>systemd-stub</refentrytitle><manvolnum>7</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>systemd.system-credentials</refentrytitle><manvolnum>7</manvolnum></citerefentry></member>
<member><citerefentry><refentrytitle>os-release</refentrytitle><manvolnum>5</manvolnum></citerefentry></member>
<member><citerefentry project='man-pages'><refentrytitle>keyctl</refentrytitle><manvolnum>1</manvolnum></citerefentry></member>
</simplelist></para>
+29
View File
@@ -119,6 +119,35 @@
</listitem>
</varlistentry>
<varlistentry>
<term><varname>keyring-setup.dm-verity.<replaceable>NAME</replaceable></varname></term>
<term><varname>keyring-setup.fs-verity.<replaceable>NAME</replaceable></varname></term>
<term><varname>keyring-setup.bpf.<replaceable>NAME</replaceable></varname></term>
<listitem>
<para>A PEM-encoded X.509 certificate to enroll into the <literal>.dm-verity</literal>,
<literal>.fs-verity</literal> or <literal>.bpf</literal> kernel keyring, respectively (e.g.
<varname>keyring-setup.dm-verity.vendor</varname>). Read by
<citerefentry><refentrytitle>systemd-keyring-setup.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>,
which writes the certificate to <filename>/run/voa/</filename> before enrolling it, see there for
details. <replaceable>NAME</replaceable> may consist of lowercase letters, digits, <literal>.</literal>,
<literal>_</literal> and <literal>-</literal>.</para>
<xi:include href="version-info.xml" xpointer="v263"/>
</listitem>
</varlistentry>
<varlistentry>
<term><varname>keyring-setup.os</varname></term>
<listitem>
<para>A whitespace-separated list of OS identifiers (e.g. <literal>debian:13 debian</literal>) under
which certificates are looked up in the VOA hierarchy, instead of the ones derived from
<citerefentry><refentrytitle>os-release</refentrytitle><manvolnum>5</manvolnum></citerefentry>. Read by
<citerefentry><refentrytitle>systemd-keyring-setup.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>.</para>
<xi:include href="version-info.xml" xpointer="v263"/>
</listitem>
</varlistentry>
<varlistentry>
<term><varname>login.issue</varname></term>
<listitem>
+225 -16
View File
@@ -10,18 +10,20 @@ use core::ffi::{c_int, CStr};
use core::ptr;
use core::sync::atomic::Ordering;
use systemd_shared::chase;
use systemd_shared::chase::{self, CHASE_MAX_MODE, CHASE_MKDIR_0755, CHASE_SAFE};
use systemd_shared::conf_files::ConfFile;
use systemd_shared::cstr::{self, display};
use systemd_shared::fileio::{self, READ_FULL_FILE_FAIL_WHEN_LARGER, READ_FULL_FILE_VERIFY_REGULAR};
use systemd_shared::keyring::{self, KeySerial};
use systemd_shared::kmod::Kmod;
use systemd_shared::prelude::*;
use systemd_shared::recurse_dir::{self, RECURSE_DIR_IGNORE_DOT, RECURSE_DIR_SORT};
use systemd_shared::sys::COMMAND_EXPERIMENTAL;
use systemd_shared::table::{Table, TABLE_ERSATZ_DASH};
use systemd_shared::tmpfile::{LinkableTmpfile, LINK_TMPFILE_REPLACE};
use systemd_shared::voa::{self, Lookup};
use systemd_shared::x509::{self, Der, X509};
use systemd_shared::{fd, json, sys};
use systemd_shared::{creds, fd, json, sys};
use systemd_shared::{libcrypto_note, libkmod_note, log_openssl_errors, table_log_add_error};
static ARG_PAGER_FLAGS: AtomicPagerFlags = AtomicPagerFlags::new(0);
@@ -43,6 +45,12 @@ const KEYRING_PERM_SEALED: u32 =
const CERTIFICATE_SIZE_MAX: usize = 1024 * 1024;
const CREDENTIAL_PREFIX: &str = "keyring-setup.";
/// Cap the number of possible OSes in both the exact and bare form. 64 OSes is plenty and we can always
/// increase.
const CREDENTIAL_OS_MAX: usize = 64;
struct KeyringSpec {
name: &'static CStr,
/// The VOA role its certificates come from.
@@ -834,6 +842,187 @@ fn process_keyring(
Ok(k.data_error)
}
/// Credentials are the way to hand a certificate to an initrd without rebuilding it. Following the
/// specification's advice for verifiers retrieved from elsewhere, they are placed into the ephemeral load path
/// as artifact verifiers. Hence, masking, merging and everything else applies to them like to any other file.
/// Returns false if the credential is not a single certificate.
fn materialize_credential(
root: BorrowedFd<'_>,
creds: BorrowedFd<'_>,
cn: &CStr,
spec: &KeyringSpec,
name: &CStr,
os: &Strv,
dry_run: bool,
) -> Result<bool> {
// 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<BorrowedFd<'_>>,
os: &Strv,
args: &Args,
) -> Result<bool> {
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.<keyring>.<name>, 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}<keyring>.<name>.",
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<BorrowedFd<'_>>) -> Result<Option<Strv>> {
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<c_int> {
foreach_option! { opts,
OPTION_COMMON_HELP => return command_print_help!(),
@@ -903,24 +1092,44 @@ fn run(argv: Argv<'_>) -> Result<c_int> {
let mut outcome = Outcome::Success;
let os = match voa::os_identifiers(root.as_fd()) {
Ok((l, bare)) => {
if bare {
log_warning!(
"os-release contains characters the VOA specification does not permit, looking up the bare ID only."
);
// Its users treat its absence differently
let creds = creds::open_credentials_dir_at(root.as_fd());
let creds_dir = creds.as_ref().map(OwnedFd::as_fd).map_err(|&e| e);
let os = match os_from_credential(creds_dir) {
Ok(Some(l)) => l,
r => {
if r.is_err() {
// Bad input, but os-release is still there
outcome.data_error();
}
match voa::os_identifiers(root.as_fd()) {
Ok((l, bare)) => {
if bare {
log_warning!(
"os-release contains characters the VOA specification does not permit, looking up the bare ID only. Pass the {CREDENTIAL_PREFIX}os credential to specify identifiers explicitly."
);
}
l
}
Err(e) => {
outcome.fail(log_error_errno!(
e,
"Failed to determine the OS identifier from os-release, enrolling nothing: {e}"
));
Strv::new()
}
}
l
}
Err(e) => {
outcome.fail(log_error_errno!(
e,
"Failed to determine the OS identifier from os-release, enrolling nothing: {e}"
));
Strv::new()
}
};
// Sealing does not depend on it either
match materialize_credentials(root.as_fd(), creds_dir, &os, &args) {
Ok(true) => outcome.data_error(),
Ok(false) => {}
Err(e) => outcome.fail(e),
}
let mut t = if args.dry_run {
let mut t = Table::new(&[
c"keyring",
+1
View File
@@ -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"
+4
View File
@@ -26,6 +26,7 @@ ConditionVirtualization=!container
Type=oneshot
RemainAfterExit=yes
SuccessExitStatus=DATAERR
ImportCredential=keyring-setup.*
ExecStart={{LIBEXECDIR}}/systemd-keyring-setup
# Loading dm_verity is the only privileged operation, the keyrings belong to root anyway
@@ -46,6 +47,9 @@ RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
# The ephemeral load path credentials are placed into, kept for the consumers of the hierarchy
RuntimeDirectory=voa
RuntimeDirectoryPreserve=yes
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM
SystemCallFilter=@system-service @module