From 2640a3b0e23f15380226568375a47011adaf35f3 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 08:10:42 +0200 Subject: [PATCH] report: add a simple software-backed signing backend --- man/rules/meson.build | 5 + man/systemd-report-sign-plain@.service.xml | 66 ++++ presets/90-systemd.preset | 1 + src/report/meson.build | 10 + src/report/report-sign-plain.c | 403 ++++++++++++++++++++ src/shared/crypto-util.c | 3 + units/meson.build | 8 + units/systemd-report-sign-plain.socket | 33 ++ units/systemd-report-sign-plain@.service.in | 21 + 9 files changed, 550 insertions(+) create mode 100644 man/systemd-report-sign-plain@.service.xml create mode 100644 src/report/report-sign-plain.c create mode 100644 units/systemd-report-sign-plain.socket create mode 100644 units/systemd-report-sign-plain@.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index a3d0650e27..68184c170b 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1185,6 +1185,11 @@ manpages = [ ['systemd-remount-fs.service', '8', ['systemd-remount-fs'], ''], ['systemd-repart', '8', ['systemd-repart.service'], 'ENABLE_REPART'], ['systemd-report', '1', [], ''], + ['systemd-report-sign-plain@.service', + '8', + ['systemd-report-sign-plain', + 'systemd-report-sign-plain.socket'], + 'HAVE_OPENSSL'], ['systemd-resolved.service', '8', ['systemd-resolved'], 'ENABLE_RESOLVE'], ['systemd-rfkill.service', '8', diff --git a/man/systemd-report-sign-plain@.service.xml b/man/systemd-report-sign-plain@.service.xml new file mode 100644 index 0000000000..1602f61663 --- /dev/null +++ b/man/systemd-report-sign-plain@.service.xml @@ -0,0 +1,66 @@ + + + + + + + + systemd-report-sign-plain@.service + systemd + + + + systemd-report-sign-plain@.service + 8 + + + + systemd-report-sign-plain@.service + systemd-report-sign-plain.socket + systemd-report-sign-plain + Sign system reports with a local software key + + + + systemd-report-sign-plain@.service + systemd-report-sign-plain.socket + /usr/lib/systemd/systemd-report-sign-plain + + + + Description + + systemd-report-sign-plain@.service is a system service that signs system reports + generated by + systemd-report1. It is + a signing backend for the logic of that tool: it implements the + io.systemd.Report.Signer.Sign() Varlink method and is reached via a socket linked into + the /run/systemd/report.sign/ directory, named plain. + + The service is socket-activated (one instance per connection) via + systemd-report-sign-plain.socket, and signs the digest passed to it with a local + software key. Signatures are made with the Ed25519 (EdDSA) algorithm. + + On first use a key pair is generated automatically and stored below + /var/lib/systemd/report.sign.plain/: the private key in + local.private (PKCS#8 PEM, readable only by the root user) and the public key in + local.public (PEM, world readable). + + The public key is also exported as a system report metric. + + + + + + See Also + + systemd1 + systemd-report1 + + + + diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index 1dd89f3df9..fc9f55c5d3 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -33,6 +33,7 @@ enable systemd-nsresourced.socket enable systemd-pstore.service enable systemd-report-basic.socket enable systemd-report-cgroup.socket +enable systemd-report-sign-plain.socket enable systemd-resolved.service enable systemd-sysext.service enable systemd-timesyncd.service diff --git a/src/report/meson.build b/src/report/meson.build index 5c6cc41630..c3f1cdadcc 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -27,4 +27,14 @@ executables += [ 'report-cgroup-server.c', ), }, + libexec_template + { + 'name' : 'systemd-report-sign-plain', + 'conditions' : [ + 'HAVE_OPENSSL', + ], + 'sources' : files( + 'report-sign-plain.c', + ), + 'dependencies' : libopenssl_cflags, + }, ] diff --git a/src/report/report-sign-plain.c b/src/report/report-sign-plain.c new file mode 100644 index 0000000000..6aca27810c --- /dev/null +++ b/src/report/report-sign-plain.c @@ -0,0 +1,403 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "build.h" +#include "crypto-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "fileio.h" +#include "format-table.h" +#include "fs-util.h" +#include "help-util.h" +#include "iovec-util.h" +#include "json-util.h" +#include "lock-util.h" +#include "log.h" +#include "main-func.h" +#include "metrics.h" +#include "options.h" +#include "stat-util.h" +#include "tmpfile-util.h" +#include "varlink-io.systemd.Metrics.h" +#include "varlink-io.systemd.Report.Signer.h" +#include "varlink-util.h" + +#define REPORT_SIGN_PLAIN_DIR "/var/lib/systemd/report.sign.plain" +#define REPORT_SIGN_PLAIN_PRIVATE_KEY "local.private" +#define REPORT_SIGN_PLAIN_PUBLIC_KEY "local.public" + +#define METRIC_IO_SYSTEMD_SIGN_PLAIN_PREFIX "io.systemd.Report.SignPlain." + +static int load_key(int dir_fd, EVP_PKEY **ret) { + _cleanup_fclose_ FILE *f = NULL; + struct stat st; + int r; + + assert(dir_fd >= 0); + assert(ret); + + r = xfopenat(dir_fd, REPORT_SIGN_PLAIN_PRIVATE_KEY, "re", /* open_flags= */ 0, &f); + if (r == -ENOENT) { + *ret = NULL; + return 0; + } + if (r < 0) + return log_error_errno(r, "Failed to open private key file '%s/%s': %m", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + if (fstat(fileno(f), &st) < 0) + return log_error_errno(errno, "Failed to stat private key file: %m"); + + r = stat_verify_regular(&st); + if (r < 0) + return log_error_errno(r, "Private key file is not a regular file: %m"); + + if (st.st_uid != 0 || (st.st_mode & 0077) != 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), + "Private key file '%s/%s' is accessible by more than the root user, refusing.", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = sym_PEM_read_PrivateKey(f, /* x= */ NULL, /* cb= */ NULL, /* u= */ NULL); + if (!pkey) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to load private key from '%s/%s'.", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + log_debug("Successfully loaded private key from '%s/%s'.", REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + *ret = TAKE_PTR(pkey); + return 1; +} + +static int write_key_file(int dir_fd, EVP_PKEY *pkey, bool private_key, const char *name, mode_t mode) { + _cleanup_fclose_ FILE *f = NULL; + _cleanup_free_ char *temp = NULL; + int r; + + assert(dir_fd >= 0); + assert(pkey); + assert(name); + + r = fopen_temporary_at(dir_fd, name, &f, &temp); + if (r < 0) + return log_error_errno(r, "Failed to open key file '%s/%s' for writing: %m", REPORT_SIGN_PLAIN_DIR, name); + + CLEANUP_TMPFILE_AT(dir_fd, temp); + + if (private_key) + r = sym_PEM_write_PrivateKey(f, pkey, /* enc= */ NULL, /* kstr= */ NULL, /* klen= */ 0, /* cb= */ NULL, /* u= */ NULL); + else + r = sym_PEM_write_PUBKEY(f, pkey); + if (r <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to write %s key.", private_key ? "private" : "public"); + + if (fchmod(fileno(f), mode) < 0) + return log_error_errno(errno, "Failed to adjust key file access mode: %m"); + + r = fflush_sync_and_check(f); + if (r < 0) + return log_error_errno(r, "Failed to write key file: %m"); + + f = safe_fclose(f); + + /* Never overwrite an existing private key: we must not destroy it. The public key is derived from the + * private one and carries no secret, hence may be refreshed unconditionally. */ + if (private_key) + r = rename_noreplace(dir_fd, temp, dir_fd, name); + else + r = RET_NERRNO(renameat(dir_fd, temp, dir_fd, name)); + if (r < 0) { + if (private_key && r == -EEXIST) + return log_error_errno(r, "Private key file '%s/%s' appeared while generating a new one, refusing to overwrite it.", + REPORT_SIGN_PLAIN_DIR, name); + + return log_error_errno(r, "Failed to move key file '%s/%s' into place: %m", REPORT_SIGN_PLAIN_DIR, name); + } + + return 0; +} + +static int generate_key(int dir_fd, EVP_PKEY **ret) { + int r; + + assert(dir_fd >= 0); + assert(ret); + + /* Generate a new Ed25519 key pair. */ + + _cleanup_(EVP_PKEY_CTX_freep) EVP_PKEY_CTX *ctx = sym_EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, /* e= */ NULL); + if (!ctx) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to allocate Ed25519 key generation context."); + + if (sym_EVP_PKEY_keygen_init(ctx) <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to initialize Ed25519 key generation context."); + + log_info("Generating Ed25519 key pair for signing reports."); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + if (sym_EVP_PKEY_keygen(ctx, &pkey) <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to generate Ed25519 key pair."); + + log_info("Successfully generated Ed25519 key pair."); + + /* Write out the private key (PKCS#8 PEM; this includes the public key too), readable by root only. */ + r = write_key_file(dir_fd, pkey, /* private_key= */ true, REPORT_SIGN_PLAIN_PRIVATE_KEY, 0400); + if (r < 0) + return r; + + /* Write out the public key too, as a convenience for verification, world readable. */ + r = write_key_file(dir_fd, pkey, /* private_key= */ false, REPORT_SIGN_PLAIN_PUBLIC_KEY, 0444); + if (r < 0) + return r; + + r = RET_NERRNO(fsync(dir_fd)); + if (r < 0) + return log_error_errno(r, "Failed to sync directory '%s': %m", REPORT_SIGN_PLAIN_DIR); + + *ret = TAKE_PTR(pkey); + return 1; +} + +static int acquire_key(EVP_PKEY **ret) { + int r; + + assert(ret); + + /* Open (creating if necessary) and exclusively lock the key directory, so that loading/generating the + * key is safe against parallel invocations (we are spawned once per connection). The directory is + * created with mode 0700, so that nobody else can pre-create it with looser permissions. */ + _cleanup_close_ int dir_fd = xopenat_lock_full( + AT_FDCWD, + REPORT_SIGN_PLAIN_DIR, + O_CLOEXEC|O_DIRECTORY|O_CREAT, + /* xopen_flags= */ 0, + /* mode= */ 0700, + LOCK_BSD, + LOCK_EX); + if (dir_fd < 0) + return log_error_errno(dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_PLAIN_DIR); + + /* First try to load the key off disk. If loading fails for any reason other than the key not + * existing yet (e.g. it is malformed or has bad permissions) we propagate the error and refuse to + * touch the existing file. Only if the key is genuinely missing do we generate a fresh one. */ + r = load_key(dir_fd, ret); + if (r != 0) + return r; + + return generate_key(dir_fd, ret); +} + +static int vl_method_sign( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + static const sd_json_dispatch_field dispatch_table[] = { + { "digest", SD_JSON_VARIANT_STRING, json_dispatch_unhex_iovec, 0, SD_JSON_MANDATORY }, + { "algorithm", SD_JSON_VARIANT_STRING, NULL, 0, SD_JSON_MANDATORY }, + {} + }; + + _cleanup_(iovec_done) struct iovec digest = {}; + int r; + + assert(link); + assert(parameters); + + /* Signing is privileged: we share the Varlink server (and possibly the socket) with the unprivileged + * metrics interface, so we cannot rely on a server-wide access check and must gate this method + * ourselves. Only root (i.e. ourselves) may request signatures. */ + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &digest); + if (r != 0) + return r; + + if (!iovec_is_set(&digest)) + return sd_varlink_error_invalid_parameter_name(link, "digest"); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + r = acquire_key(&pkey); + if (r < 0) + return r; + + /* Sign the provided digest directly. We pass a NULL message digest algorithm, which for a suitable + * signing algorithm such as Ed25519 means the input bytes are signed as-is, without hashing them + * first. The caller already passes us a digest, hence we must not hash it again. */ + _cleanup_(iovec_done) struct iovec signature = {}; + r = digest_and_sign(/* md= */ NULL, pkey, digest.iov_base, digest.iov_len, &signature.iov_base, &signature.iov_len); + if (r < 0) + return log_error_errno(r, "Failed to sign digest: %m"); + + _cleanup_free_ char *pubkey = NULL; + r = openssl_pubkey_to_pem(pkey, &pubkey); + if (r < 0) + return log_error_errno(r, "Failed to extract public key: %m"); + + /* Return a single signature object, carrying the raw Ed25519 (EdDSA) signature and the public key it + * was made with, in PEM form. */ + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR( + "data", + SD_JSON_BUILD_ARRAY( + SD_JSON_BUILD_OBJECT( + JSON_BUILD_PAIR_IOVEC_BASE64("signature", &signature), + SD_JSON_BUILD_PAIR_STRING("key", pubkey))))); +} + +static int public_key_generate(const MetricFamily *mf, sd_varlink *link, void *userdata) { + int r; + + assert(mf && mf->name); + assert(link); + + /* Expose the public key the signer uses also as metrics. This has the nice effect that the signature + * will cover the public key, in the sense of a self-signed certificate. We acquire it through the + * very same logic as the Sign() method, i.e. we load it off disk, or generate it on first use if it + * is still missing. */ + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + r = acquire_key(&pkey); + if (r < 0) + return r; + + _cleanup_free_ char *pubkey = NULL; + r = openssl_pubkey_to_pem(pkey, &pubkey); + if (r < 0) + return log_error_errno(r, "Failed to extract public key: %m"); + + return metric_build_send_string( + mf, + link, + /* object= */ NULL, + pubkey, + /* fields= */ NULL); +} + +static const MetricFamily metric_family_table[] = { + { + METRIC_IO_SYSTEMD_SIGN_PLAIN_PREFIX "PublicKey", + "PEM-encoded public key used to sign system reports with the local software key", + METRIC_FAMILY_TYPE_STRING, + .generate = public_key_generate, + }, + {} +}; + +static int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_list(metric_family_table, link, parameters, flags, userdata); +} + +static int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_describe(metric_family_table, link, parameters, flags, userdata); +} + +static int vl_server(void) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL; + int r; + + r = DLOPEN_LIBCRYPTO(LOG_ERR, SD_ELF_NOTE_DLOPEN_PRIORITY_REQUIRED); + if (r < 0) + return r; + + /* No server-wide access restriction: the metrics interface is meant to be reachable unprivileged, + * while the signing method enforces root access on its own (see vl_method_sign()). */ + r = varlink_server_new(&vs, /* flags= */ 0, /* userdata= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + /* We serve two interfaces off the same server: the signing API and a metrics provider that exposes our + * public key. Either is reachable regardless of which socket the connection arrived on. */ + r = sd_varlink_server_add_interface_many( + vs, + &vl_interface_io_systemd_Report_Signer, + &vl_interface_io_systemd_Metrics); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interfaces: %m"); + + r = sd_varlink_server_bind_method_many( + vs, + "io.systemd.Report.Signer.Sign", vl_method_sign, + "io.systemd.Metrics.List", vl_method_list_metrics, + "io.systemd.Metrics.Describe", vl_method_describe_metrics); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(vs); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int help(void) { + _cleanup_(table_unrefp) Table *options = NULL; + int r; + + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_cmdline("[OPTIONS...]"); + help_abstract("Sign system reports with a local software key."); + help_section("Options"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-report-sign-plain@.service", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + int r; + + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + + FOREACH_OPTION_OR_RETURN(c, &opts) + switch (c) { + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program takes no arguments."); + + r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT); + if (r < 0) + return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program can only run as a Varlink service."); + return 1; +} + +static int run(int argc, char *argv[]) { + int r; + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/shared/crypto-util.c b/src/shared/crypto-util.c index 5359078f6c..7705436ea3 100644 --- a/src/shared/crypto-util.c +++ b/src/shared/crypto-util.c @@ -1615,6 +1615,9 @@ int digest_and_sign( if (!mdctx) return log_openssl_errors("Failed to create new EVP_MD_CTX"); + /* Note that a NULL 'md' (message digest algorithm) means to sign the provided data directly, without + * hashing it first, as long as a suitable signing algorithm is used that supports this, such as + * Ed25519 (PureEdDSA). For such algorithms callers may pass an already calculated digest as input. */ if (sym_EVP_DigestSignInit(mdctx, NULL, md, NULL, privkey) != 1) { /* Distro security policies often disable support for SHA-1. Let's return a recognizable * error for that case. */ diff --git a/units/meson.build b/units/meson.build index de7a796a1e..af7fcdb8b2 100644 --- a/units/meson.build +++ b/units/meson.build @@ -731,6 +731,14 @@ units = [ }, { 'file' : 'systemd-report-cgroup.socket' }, { 'file' : 'systemd-report-cgroup@.service.in' }, + { + 'file' : 'systemd-report-sign-plain.socket', + 'conditions' : ['HAVE_OPENSSL'], + }, + { + 'file' : 'systemd-report-sign-plain@.service.in', + 'conditions' : ['HAVE_OPENSSL'], + }, { 'file' : 'systemd-resolved.service.in', 'conditions' : ['ENABLE_RESOLVE'], diff --git a/units/systemd-report-sign-plain.socket b/units/systemd-report-sign-plain.socket new file mode 100644 index 0000000000..4a6dea580d --- /dev/null +++ b/units/systemd-report-sign-plain.socket @@ -0,0 +1,33 @@ +# 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=Report Signing Socket (Local Software Key) +Documentation=man:systemd-report-sign-plain@.service(8) +DefaultDependencies=no +Before=sockets.target shutdown.target +Conflicts=shutdown.target +WantsMountsFor=/var/lib/systemd/report.sign.plain + +# The same binary serves two interfaces off one socket: an unprivileged metrics provider exposing our public +# key, and the privileged signing API. The actual socket node hence lives in the world-accessible metrics +# directory /run/systemd/report/, and the signing entrypoint is a symlink into the root-only +# /run/systemd/report.sign/ directory. Access to the signing method itself is restricted to root by the +# service (see vl_method_sign()); the directory permissions are merely an additional barrier. +[Socket] +ListenStream=/run/systemd/report/io.systemd.Report.SignPlain +Symlinks=/run/systemd/report.sign/plain +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-report-sign-plain@.service.in b/units/systemd-report-sign-plain@.service.in new file mode 100644 index 0000000000..7778239d78 --- /dev/null +++ b/units/systemd-report-sign-plain@.service.in @@ -0,0 +1,21 @@ +# 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=Report Signing Service (Local Software Key) +Documentation=man:systemd-report-sign-plain@.service(8) +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target +WantsMountsFor=/var/lib/systemd/report.sign.plain + +[Service] +StateDirectory=systemd/report.sign.plain +StateDirectoryMode=0700 +ExecStart={{LIBEXECDIR}}/systemd-report-sign-plain