report: add support for signing reports via varlink backends + make report a varlink service (#42595)

This adds the following:

1. systemd-report gains a new --sign= option, taking a boolean. If true,
this makes systemd-report generate + systemd-report upload generate a
signed report, instead of a regular one. The signatures are collected
from Varlink-based backends.
2. One such backend is added which does a simple Ed21159 based signing
scheme.
3. this adds a new metrics source which just reports text files
symlinked into a special dir as metrics. This is used to report the
Ed21159 public key as metric, by default, if it exists.
4. finally, systemd-report itself is turned into a varlink service. this
is useful for example for extracting a report from a system coming in
via the varlink/http bridge.

I thought a long time about the format of signing of reports. Initially
i intended to do this like homed's user record signing, i.e. require
normalization of the record, then normalize the record, and write it out
in dense form, since the result. Finally insert the resulting hash into
the user record itself. People have pointed me to the inherent messiness
of signing JSON this way though, as it requires any participant that
wishes to sign/authenticate records this way to implement the exact same
normalization/formatting rules, and in particular in the area of
floating point numbers (of which metrics presumably will have many) this
is quite problematic.

This signing hence goes a different way. instead of expecting
signer+verifier to independently come to the same normalized text form
of the json data, let's instead output a JSON-SEQ sequence, where the
first object is the report, and any subsequent objects are one signature
each. the signatures are supposed to cover the precise binary
representation of the first element in the JSON-SEQ stream. (i.e. from
the RS to the NL).

or in other words: a verifier would receive the JSON-SEQ stream, split
it up before each RS. Then it would leave object 1 unparsed for the
moment, and parse objects 2…n. It would then authenticate object 1's
precise binary representation with objects 2…n. Once that checks out, it
would parse object 1, and use it as report.
This commit is contained in:
Lennart Poettering
2026-06-19 20:37:25 +02:00
committed by GitHub
38 changed files with 1846 additions and 174 deletions
+108 -1
View File
@@ -109,7 +109,9 @@ CERTDIR=$(mktemp -d)
at_exit() {
set +e
systemctl stop fake-report-server fake-report-server-tls
rm -rf "$CERTDIR"
systemctl stop systemd-report.socket systemd-report-files.socket systemd-report-sign-plain.socket
rm -f /run/systemd/report.files/testreportfile /run/systemd/report.files/binaryfile
rm -rf "$CERTDIR" "${SIGN_WORK:-}"
}
trap at_exit EXIT
@@ -132,3 +134,108 @@ systemctl status fake-report-server-tls
"$REPORT" upload --url=https://localhost:8090/ --key=- --trust="$CERTDIR/server.crt" \
--extra-header='Authorization: Bearer magic string'
# -----------------------------------------------------------------------------
# Test the systemd-report-files@.service metric provider: files dropped into one
# of the report.files directories are exposed verbatim as io.systemd.Files.*
# metrics, keyed by their file name.
mkdir -p /run/systemd/report.files
REPORT_FILE_CONTENT="hello from the report files metric provider"
printf '%s' "$REPORT_FILE_CONTENT" >/run/systemd/report.files/testreportfile
systemctl start systemd-report-files.socket
varlinkctl info /run/systemd/report/io.systemd.Files
varlinkctl list-methods /run/systemd/report/io.systemd.Files
# The metric value must be the exact file contents.
files_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.List {})"
files_value() { echo "$files_metrics" | jq --seq -r "select(.name == \"$1\") | .value"; }
[ "$(files_value io.systemd.Files.testreportfile)" = "$REPORT_FILE_CONTENT" ]
# Describe must list the very same metric, of type string.
files_describe="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.Describe {})"
echo "$files_describe" | jq --seq -r 'select(.name == "io.systemd.Files.testreportfile") | .type' | grep -wx string >/dev/null
# Files that aren't valid UTF-8 text must be skipped silently (the value is a
# JSON string, so only text files can be reported).
printf '\xff\xfe binary garbage' >/run/systemd/report.files/binaryfile
files_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.List {})"
[ -z "$(files_value io.systemd.Files.binaryfile)" ]
rm -f /run/systemd/report.files/binaryfile
# -----------------------------------------------------------------------------
# Test report signing through the plain software backend, driven entirely via
# the io.systemd.Report Varlink interface (the GenerateSigned method). The plain
# backend is only built/installed with OpenSSL support, so skip if unavailable.
if ! systemctl cat systemd-report-sign-plain.socket &>/dev/null; then
echo "systemd-report-sign-plain.socket is not installed, skipping report signing test."
exit 0
fi
systemctl start systemd-report.socket
systemctl start systemd-report-sign-plain.socket
SIGN_WORK="$(mktemp -d)"
# Ask systemd-report to generate a *signed* report over Varlink. The reply
# carries the signed report as base64-encoded JSON-SEQ data.
varlinkctl call /run/systemd/io.systemd.Report io.systemd.Report.GenerateSigned \
'{"matches":["io.systemd.Manager.UnitsTotal"]}' | jq -r .reportData | base64 -d >"$SIGN_WORK/report.seq"
# The first JSON-SEQ record is the report itself. This is exactly the byte
# sequence that got signed, including the leading record separator (0x1e) and
# the trailing newline, so 'head -n1' reproduces it verbatim.
head -n1 "$SIGN_WORK/report.seq" >"$SIGN_WORK/message.bin"
tr -d '\036' <"$SIGN_WORK/message.bin" | jq -e '.mediaType == "application/vnd.io.systemd.report"' >/dev/null
# The remaining record(s) are signature objects. With only the plain backend
# enabled there is exactly one, with mechanism "plain".
sig_json="$(tail -n +2 "$SIGN_WORK/report.seq" | tr -d '\036')"
[ "$(echo "$sig_json" | jq -r .mechanism)" = "plain" ]
[ "$(echo "$sig_json" | jq -r .mediaType)" = "application/vnd.io.systemd.report.signature" ]
# The sha256 recorded in the signature must match the digest of the report bytes.
[ "$(echo "$sig_json" | jq -r .sha256)" = "$(sha256sum "$SIGN_WORK/message.bin" | cut -d' ' -f1)" ]
# Extract the embedded public key and the raw Ed25519 signature.
echo "$sig_json" | jq -r .data.key >"$SIGN_WORK/pub.pem"
echo "$sig_json" | jq -r .data.signature | base64 -d >"$SIGN_WORK/sig.bin"
# The signature is made over the SHA256 digest bytes directly (Ed25519, the
# signer does not pre-hash), so verify the raw digest against the signature.
openssl dgst -sha256 -binary "$SIGN_WORK/message.bin" >"$SIGN_WORK/digest.bin"
openssl pkeyutl -verify -pubin -inkey "$SIGN_WORK/pub.pem" -rawin \
-in "$SIGN_WORK/digest.bin" -sigfile "$SIGN_WORK/sig.bin"
# The public key embedded in the report must be the very key the plain backend
# generated and persisted on disk.
diff <(openssl pkey -pubin -in "$SIGN_WORK/pub.pem" -pubout) \
<(openssl pkey -pubin -in /var/lib/systemd/report.sign.plain/local.public -pubout)
# A tampered report must no longer verify against the signature.
printf 'x' >>"$SIGN_WORK/message.bin"
openssl dgst -sha256 -binary "$SIGN_WORK/message.bin" >"$SIGN_WORK/digest-bad.bin"
if openssl pkeyutl -verify -pubin -inkey "$SIGN_WORK/pub.pem" -rawin \
-in "$SIGN_WORK/digest-bad.bin" -sigfile "$SIGN_WORK/sig.bin"; then
echo "Signature verification unexpectedly succeeded for tampered report" >&2
exit 1
fi
# -----------------------------------------------------------------------------
# The plain backend also acts as a metric provider, exposing its public signing
# key as the io.systemd.Report.SignPlain.PublicKey metric. This is served off the
# very same socket as the signer, reachable in the metrics directory under
# /run/systemd/report/ (the signer endpoint is just a symlink into the root-only
# /run/systemd/report.sign/ directory pointing at the same socket).
varlinkctl info /run/systemd/report/io.systemd.Report.SignPlain
varlinkctl list-methods /run/systemd/report/io.systemd.Report.SignPlain
# The exposed public key must be the very key persisted on disk.
sign_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Report.SignPlain io.systemd.Metrics.List {})"
echo "$sign_metrics" | jq --seq -r 'select(.name == "io.systemd.Report.SignPlain.PublicKey") | .value' >"$SIGN_WORK/metric-pub.pem"
diff <(openssl pkey -pubin -in "$SIGN_WORK/metric-pub.pem" -pubout) \
<(openssl pkey -pubin -in /var/lib/systemd/report.sign.plain/local.public -pubout)
# Describe must list the very same metric, of type string.
varlinkctl call --more /run/systemd/report/io.systemd.Report.SignPlain io.systemd.Metrics.Describe {} |
jq --seq -r 'select(.name == "io.systemd.Report.SignPlain.PublicKey") | .type' | grep -wx string >/dev/null