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.
This commit is contained in:
Zbigniew Jędrzejewski-Szmek 2023-06-06 21:06:20 +02:00
parent b09a5315f5
commit a1c80efddc
3 changed files with 188 additions and 6 deletions

View file

@ -25,6 +25,7 @@
<command>/usr/lib/systemd/ukify</command>
<arg choice="opt" rep="repeat">OPTIONS</arg>
<arg choice="plain">build</arg>
<arg choice="plain">genkey</arg>
</cmdsynopsis>
</refsynopsisdiv>

View file

@ -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))

View file

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