Skip to content

Commit

Permalink
Allow specifying OpenPGP implementation to use for signing
Browse files Browse the repository at this point in the history
  • Loading branch information
wiktor-k authored and behrmann committed Oct 28, 2024
1 parent 415be3f commit f509490
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ./
Expand Down
32 changes: 32 additions & 0 deletions mkosi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions mkosi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1750,6 +1750,7 @@ class Config:
passphrase: Optional[Path]
checksum: bool
sign: bool
openpgp_tool: str
key: Optional[str]

tools_tree: Optional[Path]
Expand Down Expand Up @@ -3006,6 +3007,13 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
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",
Expand Down Expand Up @@ -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})
"""

Expand Down
8 changes: 8 additions & 0 deletions mkosi/resources/man/mkosi.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 11 additions & 4 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +61,7 @@ def mkosi(
user: Optional[int] = None,
group: Optional[int] = None,
check: bool = True,
env: Mapping[str, str] = os.environ,
) -> CompletedProcess:
return run(
[
Expand All @@ -76,10 +77,15 @@ def mkosi(
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",
Expand All @@ -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",
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def test_config() -> None:
"MinimumVersion": "123",
"Mirror": null,
"NSpawnSettings": null,
"OpenPGPTool": "gpg",
"Output": "outfile",
"OutputDirectory": "/your/output/here",
"OutputMode": 83,
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions tests/test_signing.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit f509490

Please sign in to comment.