diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29e1ded8f4..4e8be74cc5 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 2210b3918a..59882a236e 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -2236,6 +2236,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 @@ -2273,6 +2281,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 28a0c90bd8..175a781fa4 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -1702,7 +1702,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] @@ -2958,6 +2959,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", @@ -4620,6 +4627,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 0d2c29f980..d2e2f2fcfb 100644 --- a/mkosi/resources/man/mkosi.1.md +++ b/mkosi/resources/man/mkosi.1.md @@ -1195,6 +1195,11 @@ 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 a given Stateless + OpenPGP (SOP) tool for signing the `SHA256SUMS` file. + `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 be24e32427..a91ca7e836 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( [ @@ -77,10 +78,15 @@ def mkosi( stderr=sys.stderr, 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", @@ -103,7 +109,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", @@ -112,6 +118,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 3b64356d01..8059dc4ef4 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, @@ -459,6 +460,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..acff80772a --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,90 @@ +# 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 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) + + signing_key.chmod(0o744) + + # 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)