From 0e3e4b217d5357cfa21e0611ef1f6776f2c1e287 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 29 Sep 2026 17:48:36 -0700 Subject: [PATCH] sign: Sign spawn kernels for kexec_file_load() verification A bzImage's PE signature does not survive kerf extracting the ELF vmlinux, so a host that enforces kexec signatures refuses every spawn kernel kerf loads. The multikernel kernel now verifies a PKCS#7 signature appended to the ELF vmlinux in the module signature format. Add "kerf sign", which extracts the vmlinux from a bzImage when needed and appends that signature with an RSA or ECDSA key. It is a separate command so the signing key stays on the build machine rather than on every host that loads kernels. The PKCS#7 message is assembled here instead of by cryptography's PKCS#7 builder, which labels RSA signatures sha256WithRSAEncryption; the kernel only accepts rsaEncryption and would refuse them. For RSA the output is byte-for-byte what scripts/sign-file writes. kerf load now warns when it loads a bzImage on a host that enforces signatures, and explains signature errors from kexec_file_load(), including EPERM from lockdown, which it used to report as a missing root privilege. Signed-off-by: Cong Wang --- README.md | 6 + pyproject.toml | 1 + src/kerf/cli.py | 2 + src/kerf/load/main.py | 26 +++- src/kerf/sign/__init__.py | 21 ++++ src/kerf/sign/main.py | 77 ++++++++++++ src/kerf/signature.py | 221 +++++++++++++++++++++++++++++++++ tests/test_signature.py | 248 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 src/kerf/sign/__init__.py create mode 100644 src/kerf/sign/main.py create mode 100644 src/kerf/signature.py create mode 100644 tests/test_signature.py diff --git a/README.md b/README.md index c0792c2..5c56282 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,11 @@ kerf create database --cpu-count=8 --memory=16GB kerf load --kernel=/boot/vmlinuz --initrd=/boot/initrd.img \ --cmdline="root=/dev/sda1 ro" --id=1 +# Sign a spawn kernel where the signing key is kept, not on the host; +# the host kernel must trust the certificate, for example as a MOK +kerf sign /boot/vmlinuz -o vmlinux.signed --key signing_key.pem --cert signing_cert.pem +kerf load --kernel=vmlinux.signed --id=1 + # Boot a kernel instance kerf start web-server @@ -310,6 +315,7 @@ The kernel exposes a filesystem interface (mounted at `/sys/fs/multikernel/`) th [tool.poetry.dependencies] python = "^3.8" pylibfdt = "^1.7.0" # Device tree parsing (from dtc project) +cryptography = ">=3.1" # Signing spawn kernels (kerf sign) ``` ### Installation diff --git a/pyproject.toml b/pyproject.toml index 2ef6a24..8d57af5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ pylibfdt = "^1.7.0" click = "^8.0.0" pyyaml = "^6.0" pyudev = "^0.24.0" +cryptography = ">=3.1" [tool.poetry.group.dev.dependencies] pytest = "^7.0.0" diff --git a/src/kerf/cli.py b/src/kerf/cli.py index 733a5e0..394fa82 100644 --- a/src/kerf/cli.py +++ b/src/kerf/cli.py @@ -29,6 +29,7 @@ from .show.main import show from .dump.main import dump from .console.main import console +from .sign.main import sign @click.group() @@ -54,6 +55,7 @@ def main(ctx, debug): main.add_command(show) main.add_command(dump) main.add_command(console) +main.add_command(sign) if __name__ == "__main__": diff --git a/src/kerf/load/main.py b/src/kerf/load/main.py index 86efb20..7ce53cf 100644 --- a/src/kerf/load/main.py +++ b/src/kerf/load/main.py @@ -17,6 +17,7 @@ """ import ctypes +import errno import os import platform import sys @@ -32,6 +33,7 @@ save_instance_metadata, ) from ..utils import get_instance_id_from_name, get_instance_name_from_id +from ..signature import kexec_signatures_enforced from ..vmlinuz import BZIMAGE_HEADER_SIZE, VmlinuzError, is_bzimage, open_kernel_fd @@ -521,6 +523,12 @@ def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,to kernel_is_bzimage = is_bzimage(f.read(BZIMAGE_HEADER_SIZE)) if kernel_is_bzimage and verbose: click.echo("bzImage detected, extracting embedded vmlinux") + if kernel_is_bzimage and kexec_signatures_enforced(): + click.echo( + "Warning: this host enforces kernel signatures, and a bzImage is " + "loaded as its extracted, unsigned vmlinux. Sign it with 'kerf sign'.", + err=True, + ) kernel_fd = open_kernel_fd(kernel_path) except VmlinuzError as e: click.echo(f"Error: {e}", err=True) @@ -583,8 +591,24 @@ def load( # pylint: disable=too-many-arguments,too-many-positional-arguments,to except OSError as e: click.echo(f"Error: kexec_file_load failed: {e}", err=True) - if e.errno == 1: # EPERM + if e.errno == errno.EPERM and os.geteuid() == 0: + click.echo( + "Note: Refused by kernel lockdown; the kernel image must be signed " + "(see 'kerf sign').", err=True + ) + elif e.errno == 1: # EPERM click.echo("Note: This operation requires root privileges", err=True) + elif e.errno == errno.ENODATA: + click.echo( + "Note: The kernel image is not signed and this host requires " + "signatures. Sign it with 'kerf sign'.", err=True + ) + elif e.errno in (errno.ENOKEY, errno.EKEYREJECTED, errno.EKEYEXPIRED, + errno.EKEYREVOKED, errno.EBADMSG): + click.echo( + "Note: The kernel signature was rejected. The signing certificate " + "must be trusted by this host (for example enrolled as a MOK).", err=True + ) elif e.errno == 16: # EBUSY click.echo( f"Note: Instance '{instance_name}' already has a kernel loaded. " diff --git a/src/kerf/sign/__init__.py b/src/kerf/sign/__init__.py new file mode 100644 index 0000000..578bd88 --- /dev/null +++ b/src/kerf/sign/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Kernel signing subcommand implementation. +""" + +from .main import sign + +__all__ = ["sign"] diff --git a/src/kerf/sign/main.py b/src/kerf/sign/main.py new file mode 100644 index 0000000..3d56f6e --- /dev/null +++ b/src/kerf/sign/main.py @@ -0,0 +1,77 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Sign a spawn kernel so kexec_file_load() can verify it. + +Run this where the signing key is kept, such as a build machine, not on +the hosts that load kernels. +""" + +import sys +from pathlib import Path + +import click + +from ..signature import HASH_ALGORITHMS, SignatureError, is_signed, load_signing_key, sign_kernel +from ..vmlinuz import VmlinuzError, extract_vmlinux, is_bzimage + + +@click.command() +@click.argument("kernel", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "-o", "--output", required=True, type=click.Path(dir_okay=False, path_type=Path), + help="Signed ELF vmlinux to write", +) +@click.option( + "--key", "key_path", required=True, type=click.Path(exists=True, dir_okay=False), + help="Private key, PEM or DER", +) +@click.option( + "--cert", "cert_path", type=click.Path(exists=True, dir_okay=False), + help="X.509 certificate, PEM or DER (default: read from --key)", +) +@click.option( + "--hash", "hash_name", type=click.Choice(HASH_ALGORITHMS), default="sha256", + show_default=True, help="Digest algorithm", +) +@click.option( + "--password", envvar="KERF_SIGN_PASSWORD", + help="Private key password (or $KERF_SIGN_PASSWORD)", +) +def sign(kernel, output, key_path, cert_path, hash_name, password): + """Sign KERNEL for kexec_file_load() signature verification. + + KERNEL may be a bzImage, from which the embedded ELF vmlinux is + extracted, or an ELF vmlinux. An existing signature is replaced. The + signing certificate must be trusted by the host kernel, for example + enrolled as a MOK. + """ + try: + key, cert = load_signing_key(key_path, cert_path, password) + data = kernel.read_bytes() + if is_bzimage(data): + data = extract_vmlinux(data) + elif is_signed(data): + click.echo(f"Replacing the existing signature on {kernel}") + signed = sign_kernel(data, key, cert, hash_name) + output.write_bytes(signed) + except (SignatureError, VmlinuzError) as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + except OSError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + click.echo(f"Signed {output} with {cert.subject.rfc4514_string()} ({hash_name})") diff --git a/src/kerf/signature.py b/src/kerf/signature.py new file mode 100644 index 0000000..3f96d39 --- /dev/null +++ b/src/kerf/signature.py @@ -0,0 +1,221 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Kernel image signatures in the kernel's module signature format. + +A signed image is the image followed by a detached PKCS#7 signature, a +struct module_signature, and the "~Module signature appended~" marker, +the layout scripts/sign-file writes and kexec_file_load() verifies for an +ELF vmlinux. +""" + +import gzip +import struct +from pathlib import Path + +from .exceptions import KerfError +from .vmlinuz import ELF_MAGIC + +MODULE_SIG_STRING = b"~Module signature appended~\n" +PKEY_ID_PKCS7 = 2 + +# struct module_signature: algo, hash, id_type, signer_len, key_id_len, +# __pad[3], __be32 sig_len +_SIG_INFO = struct.Struct(">BBBBB3xI") + +HASH_ALGORITHMS = ("sha256", "sha384", "sha512") + +LOCKDOWN_PATH = "/sys/kernel/security/lockdown" +KCONFIG_PATH = "/proc/config.gz" + + +class SignatureError(KerfError): + """Raised when a kernel image cannot be signed or its signature parsed.""" + + +def split_signature(data: bytes) -> "tuple[bytes, bytes | None]": + """Split an image into its content and appended PKCS#7 signature, if any.""" + if not data.endswith(MODULE_SIG_STRING): + return data, None + + end = len(data) - len(MODULE_SIG_STRING) + if end < _SIG_INFO.size: + raise SignatureError("signature marker present but signature info is truncated") + algo, hash_id, id_type, signer_len, key_id_len, sig_len = _SIG_INFO.unpack_from( + data, end - _SIG_INFO.size + ) + end -= _SIG_INFO.size + if id_type != PKEY_ID_PKCS7: + raise SignatureError(f"unsupported signature type {id_type}, expected PKCS#7") + if algo or hash_id or signer_len or key_id_len: + raise SignatureError("PKCS#7 signature info has unexpected non-zero fields") + if sig_len >= end: + raise SignatureError("signature length exceeds image size") + return data[: end - sig_len], data[end - sig_len : end] + + +def is_signed(data: bytes) -> bool: + return data.endswith(MODULE_SIG_STRING) + + +def load_signing_key(key_path, cert_path=None, password=None): + """ + Load a private key and its X.509 certificate from PEM or DER files. + + Without cert_path the certificate is read from the key file, which may + hold both, as scripts/sign-file accepts. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + key_data = Path(key_path).read_bytes() + pw = password.encode() if isinstance(password, str) else password + try: + if b"-----BEGIN" in key_data: + key = serialization.load_pem_private_key(key_data, password=pw) + else: + key = serialization.load_der_private_key(key_data, password=pw) + except (ValueError, TypeError) as e: + raise SignatureError(f"{key_path}: cannot load private key: {e}") from e + + cert_data = Path(cert_path).read_bytes() if cert_path else key_data + try: + if b"-----BEGIN CERTIFICATE" in cert_data: + cert = x509.load_pem_x509_certificate(cert_data) + else: + cert = x509.load_der_x509_certificate(cert_data) + except ValueError as e: + raise SignatureError(f"{cert_path or key_path}: cannot load certificate: {e}") from e + + def _der(public_key): + return public_key.public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + + if _der(cert.public_key()) != _der(key.public_key()): + raise SignatureError("certificate does not match the private key") + return key, cert + + +def _der(tag: int, content: bytes) -> bytes: + if len(content) < 0x80: + length = bytes([len(content)]) + else: + raw = len(content).to_bytes((len(content).bit_length() + 7) // 8, "big") + length = bytes([0x80 | len(raw)]) + raw + return bytes([tag]) + length + content + + +def _seq(*items: bytes) -> bytes: + return _der(0x30, b"".join(items)) + + +def _set(*items: bytes) -> bytes: + return _der(0x31, b"".join(items)) + + +def _int(value: int) -> bytes: + return _der(0x02, value.to_bytes(value.bit_length() // 8 + 1, "big")) + + +def _oid(dotted: str) -> bytes: + arcs = [int(a) for a in dotted.split(".")] + body = bytearray([40 * arcs[0] + arcs[1]]) + for arc in arcs[2:]: + chunk = [arc & 0x7F] + arc >>= 7 + while arc: + chunk.append(0x80 | (arc & 0x7F)) + arc >>= 7 + body += bytes(reversed(chunk)) + return _der(0x06, bytes(body)) + + +_NULL = b"\x05\x00" +_OID_SIGNED_DATA = "1.2.840.113549.1.7.2" +_OID_DATA = "1.2.840.113549.1.7.1" +_OID_RSA = "1.2.840.113549.1.1.1" +_OID_DIGEST = { + "sha256": "2.16.840.1.101.3.4.2.1", + "sha384": "2.16.840.1.101.3.4.2.2", + "sha512": "2.16.840.1.101.3.4.2.3", +} +_OID_ECDSA = { + "sha256": "1.2.840.10045.4.3.2", + "sha384": "1.2.840.10045.4.3.3", + "sha512": "1.2.840.10045.4.3.4", +} + + +def sign_kernel(data: bytes, key, cert, hash_name: str = "sha256") -> bytes: + """ + Sign an ELF vmlinux, replacing any existing signature. + + The PKCS#7 message is laid out as scripts/sign-file writes it: detached, + with no certificates and no authenticated attributes, naming the signer + by issuer and serial number so the kernel finds the key in its keyrings. + It is assembled here rather than by cryptography's PKCS#7 builder, + which labels RSA signatures sha256WithRSAEncryption; the kernel only + accepts rsaEncryption there. + """ + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa + + if hash_name not in HASH_ALGORITHMS: + raise SignatureError(f"unsupported hash {hash_name}, choose one of {HASH_ALGORITHMS}") + content, _ = split_signature(data) + if not content.startswith(ELF_MAGIC): + raise SignatureError("not an ELF vmlinux") + + algorithm = {"sha256": hashes.SHA256, "sha384": hashes.SHA384, "sha512": hashes.SHA512}[ + hash_name + ]() + if isinstance(key, rsa.RSAPrivateKey): + sig = key.sign(content, padding.PKCS1v15(), algorithm) + sig_algo = _seq(_oid(_OID_RSA), _NULL) + elif isinstance(key, ec.EllipticCurvePrivateKey): + sig = key.sign(content, ec.ECDSA(algorithm)) + sig_algo = _seq(_oid(_OID_ECDSA[hash_name])) + else: + raise SignatureError("only RSA and ECDSA keys are supported") + + digest_algo = _seq(_oid(_OID_DIGEST[hash_name])) + signer_info = _seq( + _int(1), + _seq(cert.issuer.public_bytes(), _int(cert.serial_number)), + digest_algo, + sig_algo, + _der(0x04, sig), + ) + signed_data = _seq(_int(1), _set(digest_algo), _seq(_oid(_OID_DATA)), _set(signer_info)) + pkcs7 = _seq(_oid(_OID_SIGNED_DATA), _der(0xA0, signed_data)) + + return content + pkcs7 + _SIG_INFO.pack(0, 0, PKEY_ID_PKCS7, 0, 0, len(pkcs7)) + MODULE_SIG_STRING + + +def kexec_signatures_enforced() -> bool: + """Whether this kernel refuses unsigned kexec_file_load() images.""" + try: + lockdown = Path(LOCKDOWN_PATH).read_text(encoding="utf-8") + if "[integrity]" in lockdown or "[confidentiality]" in lockdown: + return True + except OSError: + pass + + try: + with gzip.open(KCONFIG_PATH, "rt", encoding="utf-8") as f: + return any(line.strip() == "CONFIG_KEXEC_SIG_FORCE=y" for line in f) + except OSError: + return False diff --git a/tests/test_signature.py b/tests/test_signature.py new file mode 100644 index 0000000..b6aed1c --- /dev/null +++ b/tests/test_signature.py @@ -0,0 +1,248 @@ +# Copyright 2026 Multikernel Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests for kernel image signing in the module signature format. +""" + +import datetime +import gzip +import lzma +import shutil +import struct +import subprocess + +import pytest +from click.testing import CliRunner +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.x509.oid import NameOID + +from kerf import signature +from kerf.sign.main import sign +from kerf.signature import ( + MODULE_SIG_STRING, + PKEY_ID_PKCS7, + SignatureError, + is_signed, + load_signing_key, + sign_kernel, + split_signature, +) +from tests.test_vmlinuz import make_bzimage, make_elf64 + + +def make_cert(key, name="kerf test"): + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)]) + now = datetime.datetime.now(datetime.timezone.utc) + return ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=30)) + .sign(key, hashes.SHA256()) + ) + + +@pytest.fixture(name="signer", params=["rsa", "ec"]) +def fixture_signer(request): + if request.param == "rsa": + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + else: + key = ec.generate_private_key(ec.SECP256R1()) + return key, make_cert(key) + + +def write_key_files(tmp_path, key, cert): + key_path = tmp_path / "signing_key.pem" + cert_path = tmp_path / "signing_cert.pem" + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + return key_path, cert_path + + +class TestSignKernel: + def test_appends_module_signature_trailer(self, signer): + key, cert = signer + elf = make_elf64() + signed = sign_kernel(elf, key, cert) + + assert signed.startswith(elf) + assert signed.endswith(MODULE_SIG_STRING) + info = signed[-len(MODULE_SIG_STRING) - 12 : -len(MODULE_SIG_STRING)] + algo, hash_id, id_type, signer_len, key_id_len, sig_len = struct.unpack( + ">BBBBB3xI", info + ) + assert (algo, hash_id, signer_len, key_id_len) == (0, 0, 0, 0) + assert id_type == PKEY_ID_PKCS7 + assert len(signed) == len(elf) + sig_len + 12 + len(MODULE_SIG_STRING) + + def test_split_recovers_content(self, signer): + key, cert = signer + elf = make_elf64() + content, sig = split_signature(sign_kernel(elf, key, cert)) + assert content == elf + assert sig and sig[0] == 0x30 # DER SEQUENCE + + def test_unsigned_image_has_no_signature(self): + elf = make_elf64() + assert split_signature(elf) == (elf, None) + assert not is_signed(elf) + + def test_resigning_replaces_signature(self, signer): + key, cert = signer + elf = make_elf64() + once = sign_kernel(elf, key, cert) + twice = sign_kernel(once, key, cert, "sha512") + assert split_signature(twice)[0] == elf + assert twice.count(MODULE_SIG_STRING) == 1 + + def test_signer_algorithm_is_what_the_kernel_accepts(self, signer): + # The kernel's PKCS#7 parser accepts rsaEncryption, not + # sha256WithRSAEncryption, for RSA signers + key, cert = signer + _, sig = split_signature(sign_kernel(make_elf64(), key, cert)) + rsa_encryption = bytes.fromhex("06092a864886f70d010101") + sha256_with_rsa = bytes.fromhex("06092a864886f70d01010b") + ecdsa_with_sha256 = bytes.fromhex("06082a8648ce3d040302") + assert sha256_with_rsa not in sig + if isinstance(key, rsa.RSAPrivateKey): + assert rsa_encryption in sig + else: + assert ecdsa_with_sha256 in sig + + def test_rejects_non_elf(self, signer): + key, cert = signer + with pytest.raises(SignatureError, match="not an ELF"): + sign_kernel(b"MZ not an elf", key, cert) + + def test_rejects_unknown_hash(self, signer): + key, cert = signer + with pytest.raises(SignatureError, match="unsupported hash"): + sign_kernel(make_elf64(), key, cert, "md5") + + def test_rejects_truncated_trailer(self): + with pytest.raises(SignatureError): + split_signature(b"\x7fELF" + MODULE_SIG_STRING) + + @pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl not installed") + def test_signature_verifies_with_openssl(self, tmp_path, signer): + key, cert = signer + content, sig = split_signature(sign_kernel(make_elf64(), key, cert)) + (tmp_path / "content").write_bytes(content) + (tmp_path / "sig.p7").write_bytes(sig) + (tmp_path / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + def verify(): + return subprocess.run( + ["openssl", "cms", "-verify", "-binary", "-inform", "DER", + "-in", str(tmp_path / "sig.p7"), "-content", str(tmp_path / "content"), + "-certfile", str(tmp_path / "cert.pem"), "-noverify", "-out", "/dev/null"], + capture_output=True, check=False, + ) + + assert verify().returncode == 0 + (tmp_path / "content").write_bytes(content + b"x") + assert verify().returncode != 0 + + +class TestLoadSigningKey: + def test_separate_key_and_cert(self, tmp_path, signer): + key_path, cert_path = write_key_files(tmp_path, *signer) + _, cert = load_signing_key(key_path, cert_path) + assert cert == signer[1] + + def test_combined_pem(self, tmp_path, signer): + key_path, cert_path = write_key_files(tmp_path, *signer) + combined = tmp_path / "combined.pem" + combined.write_bytes(key_path.read_bytes() + cert_path.read_bytes()) + _, cert = load_signing_key(combined) + assert cert == signer[1] + + def test_der_cert(self, tmp_path, signer): + key_path, _ = write_key_files(tmp_path, *signer) + der = tmp_path / "cert.der" + der.write_bytes(signer[1].public_bytes(serialization.Encoding.DER)) + _, cert = load_signing_key(key_path, der) + assert cert == signer[1] + + def test_mismatched_cert(self, tmp_path, signer): + key_path, _ = write_key_files(tmp_path, *signer) + other = ec.generate_private_key(ec.SECP256R1()) + other_cert = tmp_path / "other.pem" + other_cert.write_bytes(make_cert(other).public_bytes(serialization.Encoding.PEM)) + with pytest.raises(SignatureError, match="does not match"): + load_signing_key(key_path, other_cert) + + +class TestSignCommand: + def test_signs_bzimage_as_extracted_vmlinux(self, tmp_path, signer): + key_path, cert_path = write_key_files(tmp_path, *signer) + elf = make_elf64() + kernel = tmp_path / "bzImage" + kernel.write_bytes(make_bzimage(lzma.compress(elf, format=lzma.FORMAT_XZ))) + output = tmp_path / "vmlinux.signed" + + result = CliRunner().invoke( + sign, [str(kernel), "-o", str(output), "--key", str(key_path), "--cert", str(cert_path)] + ) + assert result.exit_code == 0, result.output + assert split_signature(output.read_bytes())[0] == elf + + def test_reports_bad_input(self, tmp_path, signer): + key_path, cert_path = write_key_files(tmp_path, *signer) + kernel = tmp_path / "notakernel" + kernel.write_bytes(b"garbage") + + result = CliRunner().invoke( + sign, [str(kernel), "-o", str(tmp_path / "out"), "--key", str(key_path), + "--cert", str(cert_path)] + ) + assert result.exit_code == 1 + assert "not an ELF" in result.output + + +class TestSignaturesEnforced: + def test_lockdown_integrity(self, tmp_path, monkeypatch): + lockdown = tmp_path / "lockdown" + lockdown.write_text("none [integrity] confidentiality\n") + monkeypatch.setattr(signature, "LOCKDOWN_PATH", str(lockdown)) + assert signature.kexec_signatures_enforced() + + def test_kexec_sig_force(self, tmp_path, monkeypatch): + lockdown = tmp_path / "lockdown" + lockdown.write_text("[none] integrity confidentiality\n") + config = tmp_path / "config.gz" + with gzip.open(config, "wt") as f: + f.write("CONFIG_KEXEC_SIG=y\nCONFIG_KEXEC_SIG_FORCE=y\n") + monkeypatch.setattr(signature, "LOCKDOWN_PATH", str(lockdown)) + monkeypatch.setattr(signature, "KCONFIG_PATH", str(config)) + assert signature.kexec_signatures_enforced() + + def test_not_enforced(self, tmp_path, monkeypatch): + lockdown = tmp_path / "lockdown" + lockdown.write_text("[none] integrity confidentiality\n") + monkeypatch.setattr(signature, "LOCKDOWN_PATH", str(lockdown)) + monkeypatch.setattr(signature, "KCONFIG_PATH", str(tmp_path / "missing.gz")) + assert not signature.kexec_signatures_enforced()