]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Allow specifying OpenPGP implementation to use for signing
authorWiktor Kwapisiewicz <wiktor@metacode.biz>
Thu, 19 Sep 2024 13:17:27 +0000 (15:17 +0200)
committerJörg Behrmann <behrmann@physik.fu-berlin.de>
Mon, 28 Oct 2024 13:29:58 +0000 (14:29 +0100)
Fixes: https://github.com/systemd/mkosi/issues/3042
.github/workflows/ci.yml
mkosi/__init__.py
mkosi/config.py
mkosi/resources/man/mkosi.1.md
tests/__init__.py
tests/test_json.py
tests/test_signing.py [new file with mode: 0644]

index 29e1ded8f4607ffdab9b1e85f5887196e6ba56b8..4e8be74cc5e0f5e8499004314ad01456276eb68d 100644 (file)
@@ -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: ./
index 299c72397db79dbab1c6c7f2ccd3a2e670044940..425872c0c1a8c60e19cc80b877d5c3caad9a3f42 100644 (file)
@@ -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):
index 02a54cdb03d3f107235706f298c791e3f22714be..d652c4eea985e7389232bad5ffde8108707ce277 100644 (file)
@@ -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})
 """
 
index aeb27b9ba0e04b966e6ac4a300f849bd30737f1c..626eccb8ce5528778fcdb8d2b875097c32d09a9f 100644 (file)
@@ -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.
index 419138b0073ada7530b18bc42ad38e5ab19e2174..302511604f4d8a6e81a798f6eebb718dca858adc 100644 (file)
@@ -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:
index 14919a32cef652f83e96ab93051ef51b93b881c6..2b023acc9736cd3180d3038dcd93a59564ec0fd5 100644 (file)
@@ -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 (file)
index 0000000..9b3dbf8
--- /dev/null
@@ -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)