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__':