import: Trust subkeys included in signature (#41860)

- 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.
- 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.
This commit is contained in:
Lennart Poettering
2026-07-02 21:38:03 +02:00
committed by GitHub
4 changed files with 161 additions and 10 deletions
+2
View File
@@ -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/
+10
View File
@@ -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:
+38 -10
View File
@@ -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,11 +410,20 @@ 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;
_cleanup_free_ char *keyring_copy = NULL;
const char *keyring_override, *keyring_source;
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");
@@ -438,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 },
@@ -455,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 */
@@ -467,16 +504,7 @@ static int verify_gpg(
/* Child */
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)
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;
+111
View File
@@ -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 <test@example.com>' 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" <<EOF
[Source]
Type=url-file
Path=file://$sigdir
MatchPattern=payload-@v.raw
[Target]
Type=regular-file
Path=$target
MatchPattern=payload-@v.raw
InstancesMax=3
EOF
SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" check-new
SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update
cmp "$sigdir/payload-v1.raw" "$target/payload-v1.raw"
# Negative test: Sign with a key not in the keyring
GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Other Key <other@example.com>' 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