From e50f4b7387451f30569feff66997d434ea84263d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20L=C3=BCke?= Date: Wed, 29 Apr 2026 15:06:32 +0900 Subject: [PATCH 1/3] import: Support env var to override gpg keyring By default there is a fixed keyring in /usr or /etc. But when running systemd-pull unprivileged in the user context or with a custom transfer definition as in systemd-sysupdate --definitions=./... (e.g., for local ParticleOS updates) it is limiting to require that all keys have to be part of the OS keyring or otherwise no verification can be used. Also, for testing it is valuable to point it at a different keyring. Add a SYSTEMD_OPENPGP_KEYRING env var where the omission or empty assignment sticks to the current behavior of the global OS keyrings but a keyring path given will take precedence. While an env var can leak down the process tree and is more difficult to secure for being the trust anchor the advantage is that one can directly specify it in the service unit as drop-in instead of having to patch the command invocation. Anyway it's a niche use case and thus not part of the man page. --- docs/ENVIRONMENT.md | 10 ++++++++++ src/import/pull-common.c | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 31f180beea..1d2498e206 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -508,6 +508,16 @@ is suppressed by default. operation. If not set, defaults to true. If disabled installation of images will be quicker, but not as safe. +`systemd-importd`/`systemd-pull` and `systemd-sysupdate`: + +* `$SYSTEMD_OPENPGP_KEYRING` — takes an absolute path to an OpenPGP keyring + file. If set and non-empty, signature verification on download uses this + keyring instead of the default `/etc/systemd/import-pubring.pgp` and + `/usr/lib/systemd/import-pubring.pgp` keyrings. + Useful when running unprivileged in the user context, with custom transfer + definitions (e.g. `systemd-sysupdate --definitions=…`), or for testing. + Has no effect when signature verification is disabled. + `systemd-dissect`, `systemd-nspawn` and all other tools that may operate on disk images with `--image=` or similar: diff --git a/src/import/pull-common.c b/src/import/pull-common.c index 2d36bf6e4a..a2629392d0 100644 --- a/src/import/pull-common.c +++ b/src/import/pull-common.c @@ -409,11 +409,19 @@ static int verify_gpg( _cleanup_(rm_rf_physical_and_freep) char *gpg_home = NULL; char sig_file_path[] = "/tmp/sigXXXXXX"; _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; + const char *keyring_override; int r; assert(iovec_is_valid(payload)); assert(iovec_is_valid(signature)); + /* Support using a custom keyring, see docs/ENVIRONMENT.md. */ + keyring_override = empty_to_null(secure_getenv("SYSTEMD_OPENPGP_KEYRING")); + if (keyring_override && !(path_is_absolute(keyring_override) && path_is_normalized(keyring_override))) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "$SYSTEMD_OPENPGP_KEYRING must be an absolute, normalized path, got '%s'.", + keyring_override); + r = pipe2(gpg_pipe, O_CLOEXEC); if (r < 0) return log_error_errno(errno, "Failed to create pipe for gpg: %m"); @@ -468,9 +476,11 @@ static int verify_gpg( cmd[k++] = strjoina("--homedir=", gpg_home); - /* We add the user keyring only to the command line arguments, if it's around since gpg fails - * otherwise. */ - if (access(USER_KEYRING_PATH, F_OK) >= 0) + if (keyring_override) + cmd[k++] = strjoina("--keyring=", keyring_override); + else if (access(USER_KEYRING_PATH, F_OK) >= 0) /* We add the user keyring only to the + * command line arguments, if it's around + * since gpg fails otherwise. */ cmd[k++] = "--keyring=" USER_KEYRING_PATH; else if (access(USER_KEYRING_PATH_LEGACY, F_OK) >= 0) cmd[k++] = "--keyring=" USER_KEYRING_PATH_LEGACY; From 80e12bf88cd2bba7a0e9294d8259ced45cc6a7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20L=C3=BCke?= Date: Wed, 29 Apr 2026 13:57:56 +0900 Subject: [PATCH 2/3] import: Trust subkeys included in signature With gpg sub keys one can rotate signing keys while having a stable trust anchor. So far one still had to ship the sub key out of band but a newer gpg has the option to include the sub key in the signature and import it automatically. This is safe if we only allow importing a sub key signed by the top key we already have in the key ring. Add the --auto-key-import argument to gpg to import subkeys but also set --import-options=merge-only,import-clean to restrict what we import to only be sub keys signed by the top key we have in the keyring and discard any irrelevant parts. The ugly part is that we also have to work on a temporary copy of the keyring because gpg wants to persist the added key material but we don't what that here. --- README | 2 ++ src/import/pull-common.c | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/README b/README index 5553915be0..4d03c8f012 100644 --- a/README +++ b/README @@ -270,6 +270,8 @@ REQUIREMENTS: NOTE: If using dbus < 1.9.18, you should override the default policy directory (--with-dbuspolicydir=/etc/dbus-1/system.d). polkit (optional) + gnupg >= 2.4 optional (for signature verification in: importctl, + systemd-sysupdate) To build in directory build/: meson setup build/ && ninja -C build/ diff --git a/src/import/pull-common.c b/src/import/pull-common.c index a2629392d0..9799ae959d 100644 --- a/src/import/pull-common.c +++ b/src/import/pull-common.c @@ -5,6 +5,7 @@ #include "sd-id128.h" #include "alloc-util.h" +#include "copy.h" #include "dirent-util.h" #include "escape.h" #include "fd-util.h" @@ -409,7 +410,8 @@ static int verify_gpg( _cleanup_(rm_rf_physical_and_freep) char *gpg_home = NULL; char sig_file_path[] = "/tmp/sigXXXXXX"; _cleanup_(pidref_done_sigkill_wait) PidRef pidref = PIDREF_NULL; - const char *keyring_override; + _cleanup_free_ char *keyring_copy = NULL; + const char *keyring_override, *keyring_source; int r; assert(iovec_is_valid(payload)); @@ -446,6 +448,31 @@ static int verify_gpg( goto finish; } + if (keyring_override) + keyring_source = keyring_override; + else if (access(USER_KEYRING_PATH, F_OK) >= 0) + keyring_source = USER_KEYRING_PATH; + else if (access(USER_KEYRING_PATH_LEGACY, F_OK) >= 0) + keyring_source = USER_KEYRING_PATH_LEGACY; + else + keyring_source = VENDOR_KEYRING_PATH; + + /* For whatever reason gpg --auto-key-import writes the key material embedded in the signature + * (the signing subkey) back into the keyring it verifies against instead of just using it + * temporarily. Verify against a throwaway copy in the tmp home so we never write to the + * original keyring. */ + keyring_copy = path_join(gpg_home, "keyring.gpg"); + if (!keyring_copy) { + r = log_oom(); + goto finish; + } + + r = copy_file(keyring_source, keyring_copy, 0, 0600, /* copy_flags= */ 0); + if (r < 0) { + log_error_errno(r, "Failed to copy keyring '%s': %m", keyring_source); + goto finish; + } + r = pidref_safe_fork_full( "(gpg)", (int[]) { gpg_pipe[0], -EBADF, STDERR_FILENO }, @@ -463,6 +490,8 @@ static int verify_gpg( "--no-auto-check-trustdb", "--batch", "--trust-model=always", + "--auto-key-import", + "--import-options=merge-only,import-clean", NULL, /* --homedir= */ NULL, /* --keyring= */ NULL, /* --verify */ @@ -475,18 +504,7 @@ static int verify_gpg( /* Child */ cmd[k++] = strjoina("--homedir=", gpg_home); - - if (keyring_override) - cmd[k++] = strjoina("--keyring=", keyring_override); - else if (access(USER_KEYRING_PATH, F_OK) >= 0) /* We add the user keyring only to the - * command line arguments, if it's around - * since gpg fails otherwise. */ - cmd[k++] = "--keyring=" USER_KEYRING_PATH; - else if (access(USER_KEYRING_PATH_LEGACY, F_OK) >= 0) - cmd[k++] = "--keyring=" USER_KEYRING_PATH_LEGACY; - else - cmd[k++] = "--keyring=" VENDOR_KEYRING_PATH; - + cmd[k++] = strjoina("--keyring=", keyring_copy); cmd[k++] = "--verify"; if (signature) { cmd[k++] = sig_file_path; From f04736fffd72510c8f4b9d0fc8209567967bd914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20L=C3=BCke?= Date: Wed, 29 Apr 2026 15:05:04 +0900 Subject: [PATCH 3/3] test: Add sysupdate gpg verification test The test first checks that simple signatures work and that a foreign signature is rejected. Then it also tests that sub key signatures are accepted when the public sub key is included in the signature. --- test/units/TEST-72-SYSUPDATE.sh | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index a8bc7432c1..88d7143906 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -56,6 +56,9 @@ EOF systemctl daemon-reload +SIGTEST_GPGHOME= +SIGTEST_OTHERHOME= + at_exit() { set +e @@ -67,6 +70,13 @@ at_exit() { losetup --detach "$loop_dev" done + if [ "$SIGTEST_GPGHOME" != "" ]; then + gpgconf --homedir "$SIGTEST_GPGHOME" --kill all 2>/dev/null + fi + if [ "$SIGTEST_OTHERHOME" != "" ]; then + gpgconf --homedir "$SIGTEST_OTHERHOME" --kill all 2>/dev/null + fi + rm -rf "$WORKDIR" } @@ -1081,4 +1091,105 @@ rm -rf "$CONFIGDIR" "$WORKDIR/blobs" rm -f "$WORKDIR/source/notifytest-v1.bin" "$WORKDIR/source/SHA256SUMS" \ "$WORKDIR/notify-recorder.py" "$NOTIFY_LOG" +test_signature_verification() { + if ! command -v gpg >/dev/null; then + echo "gpg not available, skipping signature verification test" + return 0 + fi + + # Checking for --auto-key-import is not enough because the merge-only/import-clean guarantee we rely on + # only works correctly with gpg 2.4 + local gpg_version gpg_rest + gpg_version="$(gpg --version | sed -n '1p' | awk '{print $NF}')" + gpg_rest="${gpg_version#*.}" + if [ "${gpg_version%%.*}" -lt 2 ] || { [ "${gpg_version%%.*}" -eq 2 ] && [ "${gpg_rest%%.*}" -lt 4 ]; }; then + echo "gpg $gpg_version too old (need >= 2.4), skipping signature verification test" + return 0 + fi + + local sigdir="$WORKDIR/sigtest-source" + local defdir="$WORKDIR/sigtest-defs" + local gpghome="$WORKDIR/sigtest-gpghome" + local other_home="$WORKDIR/sigtest-otherhome" + local target="$WORKDIR/sigtest-target" + local keyring="$WORKDIR/sigtest-keyring" + local top_fpr keys + + SIGTEST_GPGHOME="$gpghome" + SIGTEST_OTHERHOME="$other_home" + + mkdir -p "$sigdir" "$defdir" "$gpghome" "$other_home" "$target" + chmod 700 "$gpghome" "$other_home" + + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --quick-gen-key 'Test Key ' rsa2048 cert,sign never + # Capture gpg output first, then grep from a here-string to avoid grep -m1 causing a SIGPIPE + keys="$(GNUPGHOME="$gpghome" gpg --list-keys --with-colons)" + top_fpr="$(grep -m1 '^fpr:' <<< "$keys" | cut -d: -f10)" + test "$top_fpr" != "" + + GNUPGHOME="$gpghome" gpg --export --output "$keyring" + + dd if=/dev/urandom of="$sigdir/payload-v1.raw" bs=1024 count=8 status=none + (cd "$sigdir" && sha256sum payload-v1.raw > SHA256SUMS) + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --detach-sign --include-key-block --yes \ + --output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS" + + cat >"$defdir/01-sigtest.transfer" <' rsa2048 cert,sign never + dd if=/dev/urandom of="$sigdir/payload-v2.raw" bs=1024 count=8 status=none + (cd "$sigdir" && sha256sum payload-v1.raw payload-v2.raw > SHA256SUMS) + GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \ + --detach-sign --include-key-block --yes \ + --output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS" + if SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update; then + echo "ERROR: accepted an update signed by a key not in the keyring" >&2 + exit 1 + fi + if [ -f "$target/payload-v2.raw" ]; then + echo "ERROR: payload-v2 should not have been installed" >&2 + exit 1 + fi + + # Sub key test: Add a sub key the client does not have and rely on gpg + # --auto-key-import to get it from the signature. + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --quick-add-key "$top_fpr" rsa2048 sign 1y + # Make it so that only the sub key is available for signing to avoid having + # to select it by fingerprint. + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --output "$WORKDIR/sigtest-subkey-secret.gpg" \ + --export-secret-subkeys + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --yes --delete-secret-keys "$top_fpr" + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --import "$WORKDIR/sigtest-subkey-secret.gpg" + GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \ + --detach-sign --include-key-block --yes \ + --output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS" + SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update + cmp "$sigdir/payload-v2.raw" "$target/payload-v2.raw" +} + +test_signature_verification + touch /testok