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/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..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,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; 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