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