From a1c80efddc057b4d1dcddc51dbb1244e8df51752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20J=C4=99drzejewski-Szmek?= Date: Tue, 6 Jun 2023 21:06:20 +0200 Subject: [PATCH] ukify: add 'genkey' verb The idea is to make it easy to generate all the signing key and certs that can be used for local signing. The verb is the modeled after 'mkosi genkey', but there are some important differences: we generate the keys to the paths where they will be read from, both pcr signing keys and the SecureBoot certificate+key. If any of the outputs exist, operation is refused. Maybe we could add a --force option in the future, but this operation should be rare, so I think it's better to refuse to overwrite anything initially. I'm only doing a token man page change here. https://github.com/systemd/systemd/pull/27621 reworks the man page, and the changes done here would conflict heavily with that work. I'll submit a follow-up patch later. --- man/ukify.xml | 1 + src/ukify/test/test_ukify.py | 52 +++++++++++++ src/ukify/ukify.py | 141 +++++++++++++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/man/ukify.xml b/man/ukify.xml index b2e7f82d8fe..283d58b3b05 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -25,6 +25,7 @@ /usr/lib/systemd/ukify OPTIONS build + genkey diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index 3ca9b531c24..ac39a719402 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -698,5 +698,57 @@ def test_pcr_signing2(kernel_initrd, tmpdir): assert list(sig.keys()) == ['sha1'] assert len(sig['sha1']) == 6 # six items for six phases paths +def test_key_cert_generation(tmpdir): + opts = ukify.parse_args([ + 'genkey', + f"--pcr-public-key={tmpdir / 'pcr1.pub.pem'}", + f"--pcr-private-key={tmpdir / 'pcr1.priv.pem'}", + '--phases=enter-initrd enter-initrd:leave-initrd', + f"--pcr-public-key={tmpdir / 'pcr2.pub.pem'}", + f"--pcr-private-key={tmpdir / 'pcr2.priv.pem'}", + '--phases=sysinit ready', + f"--secureboot-private-key={tmpdir / 'sb.priv.pem'}", + f"--secureboot-certificate={tmpdir / 'sb.cert.pem'}", + ]) + assert opts.verb == 'genkey' + ukify.check_cert_and_keys_nonexistent(opts) + ukify.generate_keys(opts) + + if not shutil.which('openssl'): + return + + for key in (tmpdir / 'pcr1.priv.pem', + tmpdir / 'pcr2.priv.pem', + tmpdir / 'sb.priv.pem'): + out = subprocess.check_output([ + 'openssl', 'rsa', + '-in', key, + '-text', + '-noout', + ], text = True) + assert 'Private-Key' in out + assert '2048 bit' in out + + for pub in (tmpdir / 'pcr1.pub.pem', + tmpdir / 'pcr2.pub.pem'): + out = subprocess.check_output([ + 'openssl', 'rsa', + '-pubin', + '-in', pub, + '-text', + '-noout', + ], text = True) + assert 'Public-Key' in out + assert '2048 bit' in out + + out = subprocess.check_output([ + 'openssl', 'x509', + '-in', tmpdir / 'sb.cert.pem', + '-text', + '-noout', + ], text = True) + assert 'Certificate' in out + assert 'Issuer: CN = SecureBoot signing key on host' in out + if __name__ == '__main__': sys.exit(pytest.main(sys.argv)) diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 9abaefec9ae..4fc3ce2e192 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -25,8 +25,10 @@ import argparse import configparser +import contextlib import collections import dataclasses +import datetime import fnmatch import itertools import json @@ -37,6 +39,7 @@ import pydoc import re import shlex import shutil +import socket import subprocess import sys import tempfile @@ -356,6 +359,17 @@ def check_inputs(opts): check_splash(opts.splash) +def check_cert_and_keys_nonexistent(opts): + # Raise if any of the keys and certs are found on disk + paths = itertools.chain( + (opts.sb_key, opts.sb_cert), + *((priv_key, pub_key) + for priv_key, pub_key, _ in key_path_groups(opts))) + for path in paths: + if path and path.exists(): + raise ValueError(f'{path} is present') + + def find_tool(name, fallback=None, opts=None): if opts and opts.tools: for d in opts.tools: @@ -385,7 +399,7 @@ def key_path_groups(opts): if not opts.pcr_private_keys: return - n_priv = len(opts.pcr_private_keys or ()) + n_priv = len(opts.pcr_private_keys) pub_keys = opts.pcr_public_keys or [None] * n_priv pp_groups = opts.phase_path_groups or [None] * n_priv @@ -729,6 +743,116 @@ def make_uki(opts): print(f"Wrote {'signed' if sign_args_present else 'unsigned'} {opts.output}") +ONE_DAY = datetime.timedelta(1, 0, 0) + + +@contextlib.contextmanager +def temporary_umask(mask: int): + # Drop bits from umask + old = os.umask(0) + os.umask(old | mask) + try: + yield + finally: + os.umask(old) + + +def generate_key_cert_pair( + common_name: str, + keylength: int = 2048, + valid_days: int = 365 * 10, # TODO: can we drop the expiration date? +) -> tuple[bytes]: + + from cryptography import x509 + import cryptography.hazmat.primitives as hp + + # We use a keylength of 2048 bits. That is what Microsoft documents as + # supported/expected: + # https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/windows-secure-boot-key-creation-and-management-guidance?view=windows-11#12-public-key-cryptography + + now = datetime.datetime.utcnow() + + key = hp.asymmetric.rsa.generate_private_key( + public_exponent=65537, + key_size=keylength, + ) + cert = x509.CertificateBuilder( + ).subject_name( + x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)]) + ).issuer_name( + x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name)]) + ).not_valid_before( + now, + ).not_valid_after( + now + ONE_DAY * valid_days + ).serial_number( + x509.random_serial_number() + ).public_key( + key.public_key() + ).add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ).sign( + private_key=key, + algorithm=hp.hashes.SHA256(), + ) + + cert_pem = cert.public_bytes( + encoding=hp.serialization.Encoding.PEM, + ) + key_pem = key.private_bytes( + encoding=hp.serialization.Encoding.PEM, + format=hp.serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=hp.serialization.NoEncryption(), + ) + + return key_pem, cert_pem + + +def generate_priv_pub_key_pair(keylength : int = 2048) -> tuple[bytes]: + import cryptography.hazmat.primitives as hp + + key = hp.asymmetric.rsa.generate_private_key( + public_exponent=65537, + key_size=keylength, + ) + priv_key_pem = key.private_bytes( + encoding=hp.serialization.Encoding.PEM, + format=hp.serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=hp.serialization.NoEncryption(), + ) + pub_key_pem = key.public_key().public_bytes( + encoding=hp.serialization.Encoding.PEM, + format=hp.serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return priv_key_pem, pub_key_pem + + +def generate_keys(opts): + # This will generate keys and certificates and write them to the paths that + # are specified as input paths. + if opts.sb_key or opts.sb_cert: + fqdn = socket.getfqdn() + cn = f'SecureBoot signing key on host {fqdn}' + key_pem, cert_pem = generate_key_cert_pair(common_name=cn) + print(f'Writing SecureBoot private key to {opts.sb_key}') + with temporary_umask(0o077): + opts.sb_key.write_bytes(key_pem) + print(f'Writing SecureBoot certicate to {opts.sb_cert}') + opts.sb_cert.write_bytes(cert_pem) + + for priv_key, pub_key, _ in key_path_groups(opts): + priv_key_pem, pub_key_pem = generate_priv_pub_key_pair() + + print(f'Writing private key for PCR signing to {priv_key}') + with temporary_umask(0o077): + priv_key.write_bytes(priv_key_pem) + if pub_key: + print(f'Writing public key for PCR signing to {pub_key}') + pub_key.write_bytes(pub_key_pem) + + @dataclasses.dataclass(frozen=True) class ConfigItem: @staticmethod @@ -861,7 +985,7 @@ class ConfigItem: return (section_name, key, value) -VERBS = ('build',) +VERBS = ('build', 'genkey') CONFIG_ITEMS = [ ConfigItem( @@ -1253,7 +1377,7 @@ def finalize_options(opts): if opts.sign_kernel and not opts.sb_key and not opts.sb_cert_name: raise ValueError('--sign-kernel requires either --secureboot-private-key= and --secureboot-certificate= (for sbsign) or --secureboot-certificate-name= (for pesign) to be specified') - if opts.output is None: + if opts.verb == 'build' and opts.output is None: if opts.linux is None: raise ValueError('--output= must be specified when building a PE addon') suffix = '.efi' if opts.sb_key or opts.sb_cert_name else '.unsigned.efi' @@ -1277,9 +1401,14 @@ def parse_args(args=None): def main(): opts = parse_args() - check_inputs(opts) - assert opts.verb == 'build' - make_uki(opts) + if opts.verb == 'build': + check_inputs(opts) + make_uki(opts) + elif opts.verb == 'genkey': + check_cert_and_keys_nonexistent(opts) + generate_keys(opts) + else: + assert False if __name__ == '__main__':