diff --git a/mkosi.conf.d/20-arch.conf b/mkosi.conf.d/20-arch.conf index 247ffd925a..210bfc4dd5 100644 --- a/mkosi.conf.d/20-arch.conf +++ b/mkosi.conf.d/20-arch.conf @@ -11,3 +11,4 @@ Packages= python qemu-user-static shim + sequoia-sop diff --git a/mkosi.conf.d/20-debian/mkosi.conf b/mkosi.conf.d/20-debian/mkosi.conf index 62a185682d..d8763d7f8c 100644 --- a/mkosi.conf.d/20-debian/mkosi.conf +++ b/mkosi.conf.d/20-debian/mkosi.conf @@ -10,3 +10,4 @@ Repositories=non-free-firmware [Content] Packages= linux-perf + sqop diff --git a/mkosi.conf.d/20-fedora/mkosi.conf b/mkosi.conf.d/20-fedora/mkosi.conf index bf6ae2603b..c4e61794e0 100644 --- a/mkosi.conf.d/20-fedora/mkosi.conf +++ b/mkosi.conf.d/20-fedora/mkosi.conf @@ -12,3 +12,4 @@ Packages= perf qemu-user-static rpmautospec + sequoia-sop diff --git a/mkosi.conf.d/20-ubuntu/mkosi.conf b/mkosi.conf.d/20-ubuntu/mkosi.conf index e1b4b5dffa..ab36cfa42d 100644 --- a/mkosi.conf.d/20-ubuntu/mkosi.conf +++ b/mkosi.conf.d/20-ubuntu/mkosi.conf @@ -10,3 +10,4 @@ Repositories=universe [Content] Packages= linux-tools-generic + sqop diff --git a/mkosi.conf.d/30-debian-kali-ubuntu/mkosi.conf b/mkosi.conf.d/30-debian-kali-ubuntu/mkosi.conf index 74dadbac5e..1f24bdda46 100644 --- a/mkosi.conf.d/30-debian-kali-ubuntu/mkosi.conf +++ b/mkosi.conf.d/30-debian-kali-ubuntu/mkosi.conf @@ -13,3 +13,4 @@ Packages= python3 qemu-user-static shim-signed + sqop diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 28bc118456..ad12314390 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -2233,6 +2233,14 @@ def calculate_signature(context: Context) -> None: if not context.config.sign or not context.config.checksum: return + pgptool = context.config.openpgp_tool + if pgptool == "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 @@ -2270,6 +2278,37 @@ def calculate_signature(context: Context) -> None: ) +def calculate_signature_sop(context: Context) -> None: + pgptool = context.config.openpgp_tool + signing_key = context.config.key + if signing_key is None: + die("Signing key is mandatory when using SOP signing") + + cmdline: list[PathString] = [pgptool, "sign", "/signing-key.pgp"] + + options: list[PathString] = [ + "--bind", signing_key, "/signing-key.pgp", + "--bind", context.staging, workdir(context.staging), + "--bind", "/run", "/run", + ] # fmt: skip + + 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( + cmdline, + env=context.config.environment, + stdin=i, + stdout=o, + sandbox=context.sandbox( + binary=pgptool, + options=options, + ), + ) + + 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 5180ae6874..7e3e856787 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -1676,7 +1676,8 @@ class Config: passphrase: Optional[Path] checksum: bool sign: bool - key: Optional[str] + openpgp_tool: str + key: Optional[PathString] tools_tree: Optional[Path] tools_tree_distribution: Optional[Distribution] @@ -2920,6 +2921,12 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple section="Validation", help="GPG key to use for signing", ), + ConfigSetting( + dest="openpgp_tool", + section="Validation", + default="gpg", + help="OpenPGP implementation to use for signing", + ), # Build section ConfigSetting( dest="tools_tree", @@ -4562,6 +4569,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 or "gpg"}) GPG Key: ({"default" if config.key is None else config.key}) """ diff --git a/mkosi/resources/mkosi-tools/mkosi.conf b/mkosi/resources/mkosi-tools/mkosi.conf index 0e57dc25ae..fe21da9ac5 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf @@ -37,4 +37,5 @@ Packages= util-linux xfsprogs zstd + sqop SELinuxRelabel=no diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-arch.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-arch.conf index f44c33a230..f7b09e4621 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-arch.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-arch.conf @@ -23,6 +23,7 @@ Packages= qemu-base reprepro sbsigntools + sequoia-sop shadow squashfs-tools systemd-ukify diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-debian-kali-ubuntu/mkosi.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-debian-kali-ubuntu/mkosi.conf index bde44e73be..42387faf56 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-debian-kali-ubuntu/mkosi.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-debian-kali-ubuntu/mkosi.conf @@ -37,6 +37,7 @@ Packages= qemu-system reprepro sbsigntool + sqop squashfs-tools swtpm-tools systemd-container diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf index 25f37e66f3..7f979b6a2a 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/10-fedora/mkosi.conf @@ -18,5 +18,6 @@ Packages= qemu-system-ppc-core qemu-system-s390x-core reprepro + sequoia-sop ubu-keyring zypper diff --git a/tests/__init__.py b/tests/__init__.py index 419138b007..bde4817363 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,7 +6,7 @@ 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 @@ def mkosi( user: Optional[int] = None, group: Optional[int] = None, check: bool = True, + env: Optional[Mapping[str, str]] = None, ) -> CompletedProcess: return run( [ @@ -76,10 +77,15 @@ def mkosi( stdout=sys.stdout, user=user, group=group, - env=os.environ, + env=env or os.environ, ) # fmt: skip - def build(self, options: Sequence[PathString] = (), args: Sequence[str] = ()) -> CompletedProcess: + def build( + self, + options: Sequence[PathString] = (), + args: Sequence[str] = (), + env: Optional[Mapping[str, str]] = None, + ) -> CompletedProcess: kcl = [ "loglevel=6", "systemd.log_level=debug", @@ -102,7 +108,7 @@ def build(self, options: Sequence[PathString] = (), args: Sequence[str] = ()) -> *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 @@ def build(self, options: Sequence[PathString] = (), args: Sequence[str] = ()) -> 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 317152e338..501770999f 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -198,6 +198,7 @@ def test_config() -> None: "MinimumVersion": "123", "Mirror": null, "NSpawnSettings": null, + "OpenpgpTool": "gpg", "Output": "outfile", "OutputDirectory": "/your/output/here", "OutputMode": 83, @@ -457,6 +458,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 0000000000..3b07af223e --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + + +import tempfile +from collections.abc import Mapping +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: + print("Needs `sqop` binary in PATH to perform sop tests.") + return + with tempfile.TemporaryDirectory() as path: + tmp_path = Path(path) + tmp_path.chmod(0o755) + + 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) + + signing_key.chmod(0o755) + + # 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) + + signing_cert.chmod(0o755) + + with Image(config) as image: + 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: + tmp_path = Path(path) + tmp_path.chmod(0o777) + + signing_key = "mkosi-test@example.org" + signing_cert = tmp_path / "signing-cert.pgp" + gnupghome = tmp_path / ".gnupg" + + env: Mapping[str, str] = 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() + (gnupghome / "common.conf").touch() + + # create a brand new signing key + run(cmdline=["gpg", "--quick-gen-key", "--batch", "--passphrase", "", signing_key], env=env) + + # GnuPG will set 0o700 permissions so that the secret files are not available + # to other users. Since this is for tests only and we need that keyring for signing + # enable all permissions. We need write permissions since GnuPG creates temporary + # files in this directory during operation. + gnupghome.chmod(0o777) + for p in gnupghome.rglob("*"): + p.chmod(0o777) + + # export public key (certificate) + with open(signing_cert, "wb") as o: + run(cmdline=["gpg", "--export", signing_key], env=env, stdout=o) + + signing_cert.chmod(0o755) + + with open("/tmp/list", "wb") as o: + run(["ls", "-la", gnupghome], stdout=o) + + with Image(config) as image: + 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" + + with open(signed_file, "rb") as i: + run(cmdline=["sqop", "verify", signature, signing_cert], stdin=i)