From: Wiktor Kwapisiewicz Date: Thu, 19 Sep 2024 13:17:27 +0000 (+0200) Subject: Allow specifying OpenPGP implementation to use for signing X-Git-Tag: v25~205 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f509490ae0e6aadb93fa996a7e036fc2a40c9ed6;p=thirdparty%2Fmkosi.git Allow specifying OpenPGP implementation to use for signing Fixes: https://github.com/systemd/mkosi/issues/3042 --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29e1ded8f..4e8be74cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: - name: Install run: | sudo apt-get update - sudo apt-get install python3-pytest lvm2 cryptsetup-bin btrfs-progs + sudo apt-get install python3-pytest lvm2 cryptsetup-bin btrfs-progs sqop # Make sure the latest changes from the pull request are used. sudo ln -svf $PWD/bin/mkosi /usr/bin/mkosi working-directory: ./ diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 299c72397..425872c0c 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -2258,6 +2258,13 @@ def calculate_signature(context: Context) -> None: if not context.config.sign or not context.config.checksum: return + if context.config.openpgp_tool == "gpg": + calculate_signature_gpg(context) + else: + calculate_signature_sop(context) + + +def calculate_signature_gpg(context: Context) -> None: cmdline: list[PathString] = ["gpg", "--detach-sign", "--pinentry-mode", "loopback"] # Need to specify key before file to sign @@ -2295,6 +2302,31 @@ def calculate_signature(context: Context) -> None: ) +def calculate_signature_sop(context: Context) -> None: + if context.config.key is None: + die("Signing key is mandatory when using SOP signing") + + with ( + complete_step("Signing SHA256SUMS…"), + open(context.staging / context.config.output_checksum, "rb") as i, + open(context.staging / context.config.output_signature, "wb") as o, + ): + run( + [context.config.openpgp_tool, "sign", "/signing-key.pgp"], + env=context.config.environment, + stdin=i, + stdout=o, + sandbox=context.sandbox( + binary=context.config.openpgp_tool, + options=[ + "--bind", context.config.key, "/signing-key.pgp", + "--bind", context.staging, workdir(context.staging), + "--bind", "/run", "/run", + ], + ), + ) # fmt: skip + + def dir_size(path: Union[Path, os.DirEntry[str]]) -> int: dir_sum = 0 for entry in os.scandir(path): diff --git a/mkosi/config.py b/mkosi/config.py index 02a54cdb0..d652c4eea 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -1750,6 +1750,7 @@ class Config: passphrase: Optional[Path] checksum: bool sign: bool + openpgp_tool: str key: Optional[str] tools_tree: Optional[Path] @@ -3006,6 +3007,13 @@ SETTINGS = ( section="Validation", help="GPG key to use for signing", ), + ConfigSetting( + name="OpenPGPTool", + dest="openpgp_tool", + section="Validation", + default="gpg", + help="OpenPGP implementation to use for signing", + ), # Build section ConfigSetting( dest="tools_tree", @@ -4668,6 +4676,7 @@ def summary(config: Config) -> str: Passphrase: {none_to_none(config.passphrase)} Checksum: {yes_no(config.checksum)} Sign: {yes_no(config.sign)} + OpenPGP Tool: {config.openpgp_tool} GPG Key: ({"default" if config.key is None else config.key}) """ diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md index aeb27b9ba..626eccb8c 100644 --- a/mkosi/resources/man/mkosi.1.md +++ b/mkosi/resources/man/mkosi.1.md @@ -1202,6 +1202,14 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, `Sign=`, `--sign` : Sign the generated `SHA256SUMS` using `gpg` after completion. +`OpenPGPTool=`, `--openpgp-tool` +: OpenPGP implementation to use for signing. `gpg` is the default. + Selecting a value different than the default will use the given Stateless + OpenPGP (SOP) tool for signing the `SHA256SUMS` file. + + Exemplary choices are `sqop` and `rsop`, but any implementation from + https://www.openpgp.org/about/sop/ that can be installed locally will work. + `Key=`, `--key=` : Select the `gpg` key to use for signing `SHA256SUMS`. This key must be already present in the `gpg` keyring. diff --git a/tests/__init__.py b/tests/__init__.py index 419138b00..302511604 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,7 +6,7 @@ import os import subprocess import sys import uuid -from collections.abc import Iterator, Sequence +from collections.abc import Iterator, Mapping, Sequence from pathlib import Path from types import TracebackType from typing import Any, Optional @@ -61,6 +61,7 @@ class Image: user: Optional[int] = None, group: Optional[int] = None, check: bool = True, + env: Mapping[str, str] = os.environ, ) -> CompletedProcess: return run( [ @@ -76,10 +77,15 @@ class Image: stdout=sys.stdout, user=user, group=group, - env=os.environ, + env=env, ) # fmt: skip - def build(self, options: Sequence[PathString] = (), args: Sequence[str] = ()) -> CompletedProcess: + def build( + self, + options: Sequence[PathString] = (), + args: Sequence[str] = (), + env: Mapping[str, str] = os.environ, + ) -> CompletedProcess: kcl = [ "loglevel=6", "systemd.log_level=debug", @@ -102,7 +108,7 @@ class Image: *options, ] # fmt: skip - self.mkosi("summary", opt, user=self.uid, group=self.uid) + self.mkosi("summary", opt, user=self.uid, group=self.uid, env=env) return self.mkosi( "build", @@ -111,6 +117,7 @@ class Image: stdin=sys.stdin if sys.stdin.isatty() else None, user=self.uid, group=self.gid, + env=env, ) def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> CompletedProcess: diff --git a/tests/test_json.py b/tests/test_json.py index 14919a32c..2b023acc9 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -199,6 +199,7 @@ def test_config() -> None: "MinimumVersion": "123", "Mirror": null, "NSpawnSettings": null, + "OpenPGPTool": "gpg", "Output": "outfile", "OutputDirectory": "/your/output/here", "OutputMode": 83, @@ -463,6 +464,7 @@ def test_config() -> None: minimum_version=GenericVersion("123"), mirror=None, nspawn_settings=None, + openpgp_tool="gpg", output="outfile", output_dir=Path("/your/output/here"), output_format=OutputFormat.uki, diff --git a/tests/test_signing.py b/tests/test_signing.py new file mode 100644 index 000000000..9b3dbf823 --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + + +import os +import tempfile +from pathlib import Path + +import pytest + +from mkosi.run import find_binary, run + +from . import Image, ImageConfig + +pytestmark = pytest.mark.integration + + +def test_signing_checksums_with_sop(config: ImageConfig) -> None: + if find_binary("sqop", root=config.tools) is None: + pytest.skip("Needs 'sqop' binary in tools tree PATH to perform sop tests.") + + if find_binary("sqop") is None: + pytest.skip("Needs 'sqop' binary in host system PATH to perform sop tests.") + + with tempfile.TemporaryDirectory() as path, Image(config) as image: + tmp_path = Path(path) + os.chown(tmp_path, image.uid, image.gid) + + signing_key = tmp_path / "signing-key.pgp" + signing_cert = tmp_path / "signing-cert.pgp" + + # create a brand new signing key + with open(signing_key, "wb") as o: + run(cmdline=["sqop", "generate-key", "--signing-only", "Test"], stdout=o) + + # extract public key (certificate) + with open(signing_key, "rb") as i, open(signing_cert, "wb") as o: + run(cmdline=["sqop", "extract-cert"], stdin=i, stdout=o) + + image.build( + options=["--checksum=true", "--openpgp-tool=sqop", "--sign=true", f"--key={signing_key}"] + ) + + signed_file = image.output_dir / "image.SHA256SUMS" + signature = image.output_dir / "image.SHA256SUMS.gpg" + + with open(signed_file, "rb") as i: + run(cmdline=["sqop", "verify", signature, signing_cert], stdin=i) + + +def test_signing_checksums_with_gpg(config: ImageConfig) -> None: + with tempfile.TemporaryDirectory() as path, Image(config) as image: + tmp_path = Path(path) + os.chown(tmp_path, image.uid, image.gid) + + signing_key = "mkosi-test@example.org" + signing_cert = tmp_path / "signing-cert.pgp" + gnupghome = tmp_path / ".gnupg" + + env = dict(GNUPGHOME=str(gnupghome)) + + # Creating GNUPGHOME directory and appending an *empty* common.conf + # file stops GnuPG from spawning keyboxd which causes issues when switching + # users. See https://stackoverflow.com/a/72278246 for details + gnupghome.mkdir() + os.chown(gnupghome, image.uid, image.gid) + (gnupghome / "common.conf").touch() + + # create a brand new signing key + run( + cmdline=["gpg", "--quick-gen-key", "--batch", "--passphrase", "", signing_key], + env=env, + user=image.uid, + group=image.gid, + ) + + # export public key (certificate) + with open(signing_cert, "wb") as o: + run( + cmdline=["gpg", "--export", signing_key], + env=env, + stdout=o, + user=image.uid, + group=image.gid, + ) + + image.build(options=["--checksum=true", "--sign=true", f"--key={signing_key}"], env=env) + + signed_file = image.output_dir / "image.SHA256SUMS" + signature = image.output_dir / "image.SHA256SUMS.gpg" + + run(cmdline=["gpg", "--verify", signature, signed_file], env=env)