From 22b8236ff61f5427b5f266ca65b983a50502d5bc Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 2 Jul 2024 09:38:53 +0200 Subject: [PATCH 1/3] ukify: add basic .profile support This just allows including .profile sections, but doesn't try to be smart about it. This alone won't help you much to create valid multi-profile UKIs. --- man/ukify.xml | 11 +++++++++++ src/ukify/ukify.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/man/ukify.xml b/man/ukify.xml index bfe54a8f27..1c52c9414b 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -400,6 +400,17 @@ + + Profile=PATH + + + A path to a UKI profile to place in an .profile section. This + option is useful for creating multi-profile UKIs, and is typically used in combination with + , to extend the specified UKI with an additional profile. + + + + PCRBanks=PATH diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 3478ff5689..c8b6ce401b 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -313,6 +313,7 @@ DEFAULT_SECTIONS_TO_SHOW = { '.pcrsig' : 'text', '.sbat' : 'text', '.sbom' : 'binary', + '.profile' : 'text', } @dataclasses.dataclass @@ -849,6 +850,7 @@ def make_uki(opts): sections = [ # name, content, measure? + ('.profile', opts.profile, True ), ('.osrel', opts.os_release, True ), ('.cmdline', opts.cmdline, True ), ('.dtb', opts.devicetree, True ), @@ -1359,6 +1361,13 @@ CONFIG_ITEMS = [ help = 'section as name and contents [NAME section] or section to print', ), + ConfigItem( + '--profile', + metavar='TEST|@PATH', + help='Profile information [.profile section]', + config_key = 'UKI/Uname', + ), + ConfigItem( '--efi-arch', metavar = 'ARCH', From b6570095ce889b07242d36cd05fa1d1899d0bc6c Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 2 Jul 2024 10:23:07 +0200 Subject: [PATCH 2/3] ukify: add new --extend= switch for importing an existing UKI's sections to later extend This options is pretty simple, it allows specifying an UKI whose sections to import first, and place at the beginning of the new UKI. This is useful for generating multi-profile UKIs piecemeal: generate the base UKI first, then append a profile, and another one and another one. The sections imported this way are not included in any PCR signature, the assumption is that that already happened before in the imported UKI. --- man/ukify.xml | 54 ++++++++++++++++++++++++++++++ src/ukify/ukify.py | 82 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 119 insertions(+), 17 deletions(-) diff --git a/man/ukify.xml b/man/ukify.xml index 1c52c9414b..5e7681279b 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -228,6 +228,18 @@ + + + + Takes a path to an existing PE file to import into the newly generated PE file. If + this option is used all UKI PE sections of the specified PE file are copied into the target PE file + before any new PE sections are appended. This is useful for generating multi-profile UKIs. Note + that this only copies PE sections that are defined by the UKI specification, and ignores any other, + for example .text or similar. + + + + @@ -703,6 +715,48 @@ Writing public key for PCR signing to /etc/kernel/pcr-system.pub.pem by default, so after this file has been created, installations of kernels that create a UKI on the local machine using kernel-install will perform signing using this config. + + + Multi-Profile PE + + First, create the base UKI: + $ ukify build \ + --linux=/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ + --initrd=/some/path/initramfs-6.0.9-300.fc37.x86_64.img \ + --cmdline='quiet rw' \ + --output=base.efi + + + Then, extend the base UKI with information about profile @0: + + $ ukify build \ + --extend=base.efi \ + --profile='TITLE=Base' \ + --output=base-with-profile-0.efi + + + Add a second profile (@1): + + $ ukify build \ + --extend=base-with-profile-0.efi \ + --profile='TITLE=Boot into Storage Target Mode +ID=storagetm' \ + --cmdline='quiet rw rd.systemd.unit=stroage-target-mode.target' \ + --output=base-with-profile-0-1.efi + + + Add a third profile (@2): + + $ ukify build \ + --extend=base-with-profile-0-1.efi \ + --profile='TITLE=Factory Reset +ID=factory-reset' \ + --cmdline='quiet rw systemd.unit=factory-reset.target' \ + --output=base-with-profile-0-1-2.efi + + + The resulting UKI base-with-profile-0-1-2.efi will now contain three profiles. + diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index c8b6ce401b..b3fab755b6 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -381,7 +381,14 @@ class UKI: sections: list[Section] = dataclasses.field(default_factory=list, init=False) def add_section(self, section): - if section.name in [s.name for s in self.sections]: + start = 0 + + # Start search at last .profile section, if there is one + for i in range(len(self.sections)): + if self.sections[i].name == ".profile": + start = i+1 + + if section.name in [s.name for s in self.sections[start:]]: raise ValueError(f'Duplicate section {section.name}') self.sections += [section] @@ -493,6 +500,10 @@ def key_path_groups(opts): pp_groups) +def pe_strip_section_name(name): + return name.rstrip(b"\x00").decode() + + def call_systemd_measure(uki, linux, opts): measure_tool = find_tool('systemd-measure', '/usr/lib/systemd/systemd-measure', @@ -632,6 +643,9 @@ def pe_add_sections(uki: UKI, output: str): # We could strip the signatures, but why would anyone sign the stub? raise PEError('Stub image is signed, refusing.') + # Remember how many sections originate from systemd-stub + n_original_sections = len(pe.sections) + for section in uki.sections: new_section = pefile.SectionStructure(pe.__IMAGE_SECTION_HEADER_format__, pe=pe) new_section.__unpack__(b'\0' * new_section.sizeof()) @@ -665,8 +679,8 @@ def pe_add_sections(uki: UKI, output: str): # Special case, mostly for .sbat: the stub will already have a .sbat section, but we want to append # the one from the kernel to it. It should be small enough to fit in the existing section, so just # swap the data. - for i, s in enumerate(pe.sections): - if s.Name.rstrip(b"\x00").decode() == section.name: + for i, s in enumerate(pe.sections[:n_original_sections]): + if pe_strip_section_name(s.Name) == section.name: if new_section.Misc_VirtualSize > s.SizeOfRawData: raise PEError(f'Not enough space in existing section {section.name} to append new data.') @@ -702,7 +716,7 @@ def merge_sbat(input_pe: [pathlib.Path], input_text: [str]) -> str: continue for section in pe.sections: - if section.Name.rstrip(b"\x00").decode() == ".sbat": + if pe_strip_section_name(section.Name) == ".sbat": split = section.get_data().rstrip(b"\x00").decode().splitlines() if not split[0].startswith('sbat,'): print(f"{f} does not contain a valid SBAT section, skipping.") @@ -784,6 +798,28 @@ def verify(tool, opts): return tool['output'] in info + +def import_to_extend(uki, opts): + + if opts.extend is None: + return + + import_sections = ('.linux', '.osrel', '.cmdline', '.initrd', + '.ucode', '.splash', '.dtb', '.uname', + '.sbat', '.pcrsig', '.pcrpkey', '.profile') + + pe = pefile.PE(opts.extend, fast_load=True) + + for section in pe.sections: + n = pe_strip_section_name(section.Name) + + if n not in import_sections: + continue + + print(f"Copying section '{n}' from '{opts.extend}': {section.Misc_VirtualSize} bytes") + uki.add_section(Section.create(n, section.get_data(length=section.Misc_VirtualSize), measure=False)) + + def make_uki(opts): # kernel payload signing @@ -848,6 +884,9 @@ def make_uki(opts): format=serialization.PublicFormat.SubjectPublicKeyInfo, ) + # Import an existing UKI for extension + import_to_extend(uki, opts) + sections = [ # name, content, measure? ('.profile', opts.profile, True ), @@ -872,21 +911,22 @@ def make_uki(opts): for section in opts.sections: uki.add_section(section) - if linux is not None: - # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on either. - input_pes = [opts.stub, linux] - if not opts.sbat: - opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md + if opts.extend is None: + if linux is not None: + # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on either. + input_pes = [opts.stub, linux] + if not opts.sbat: + opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md uki,1,UKI,uki,1,https://uapi-group.org/specifications/specs/unified_kernel_image/ """] - else: - # Addons don't use the stub so we add SBAT manually - input_pes = [] - if not opts.sbat: - opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md + else: + # Addons don't use the stub so we add SBAT manually + input_pes = [] + if not opts.sbat: + opts.sbat = ["""sbat,1,SBAT Version,sbat,1,https://github.com/rhboot/shim/blob/main/SBAT.md uki-addon,1,UKI Addon,addon,1,https://www.freedesktop.org/software/systemd/man/latest/systemd-stub.html """] - uki.add_section(Section.create('.sbat', merge_sbat(input_pes, opts.sbat), measure=linux is not None)) + uki.add_section(Section.create('.sbat', merge_sbat(input_pes, opts.sbat), measure=linux is not None)) # PCR measurement and signing @@ -1043,7 +1083,7 @@ def generate_keys(opts): def inspect_section(opts, section): - name = section.Name.rstrip(b"\x00").decode() + name = pe_strip_section_name(section.Name) # find the config for this section in opts and whether to show it config = opts.sections_by_name.get(name, None) @@ -1383,6 +1423,14 @@ CONFIG_ITEMS = [ config_key = 'UKI/Stub', ), + ConfigItem( + '--extend', + metavar = 'UKI', + type = pathlib.Path, + help = 'path to existing UKI file whose relevant sections to insert into the UKI first', + config_key = 'UKI/Extend', + ), + ConfigItem( '--pcr-banks', metavar = 'BANK…', @@ -1687,7 +1735,7 @@ def finalize_options(opts): opts.efi_arch = guess_efi_arch() if opts.stub is None: - if opts.linux is not None: + if opts.linux is not None or opts.extend is not None: opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub') else: opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/addon{opts.efi_arch}.efi.stub') From bc3e2c5a5774ae7b212817d04e04abccf30088ae Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 2 Jul 2024 12:36:21 +0200 Subject: [PATCH 3/3] ukify: introduce new --measure-base= switch --- man/ukify.xml | 13 ++++++++ src/ukify/ukify.py | 80 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/man/ukify.xml b/man/ukify.xml index 5e7681279b..983e89c270 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -240,6 +240,19 @@ + + + + Takes a path to an existing PE file to use as base profile, for measuring + multi-profile UKIs. When calculating the PCR values, this has the effect that the sections + specified on the command line are combined with any sections from the PE file specified here (up to + the first .profile section, and only if not already specified on the command + line). Typically, this is used together with to both import and use as + measurement base an existing UKI. + + + + diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index b3fab755b6..5057d72d2c 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -505,6 +505,14 @@ def pe_strip_section_name(name): def call_systemd_measure(uki, linux, opts): + + if not opts.measure and not opts.pcr_private_keys: + return + + measure_sections = ('.linux', '.osrel', '.cmdline', '.initrd', + '.ucode', '.splash', '.dtb', '.uname', + '.sbat', '.pcrpkey', '.profile') + measure_tool = find_tool('systemd-measure', '/usr/lib/systemd/systemd-measure', opts=opts) @@ -513,16 +521,67 @@ def call_systemd_measure(uki, linux, opts): # PCR measurement + to_measure = [] + tflist = [] + + # First, pick up the sections we shall measure now */ + for s in uki.sections: + + print(s) + + if not s.measure: + continue + + if s.content is not None: + assert(s.name != ".linux" or linux is None) + to_measure.append(f"--{s.name.removeprefix('.')}={s.content}") + else: + raise ValueError(f"Don't know how to measure section {s.name}"); + + if linux is not None: + to_measure.append(f'--linux={linux}') + + # And now iterate through the base profile and measure what we haven't measured above + if opts.measure_base is not None: + pe = pefile.PE(opts.measure_base, fast_load=True) + + # Find matching PE section in base image + for base_section in pe.sections: + name = pe_strip_section_name(base_section.Name) + + # If we reach the first .profile section the base is over + if name == ".profile": + break + + # Only some sections are measured + if name not in measure_sections: + continue + + # Check if this is a section we already covered above + already_covered = False + for s in uki.sections: + if s.measure and name == s.name: + already_covered = True + break; + + if already_covered: + continue + + # Split out section and use as base + tf = tempfile.NamedTemporaryFile() + tf.write(base_section.get_data(length=base_section.Misc_VirtualSize)) + tf.flush() + tflist.append(tf) + + to_measure.append(f"--{name.removeprefix('.')}={tf.name}") + if opts.measure: pp_groups = opts.phase_path_groups or [] cmd = [ measure_tool, 'calculate', - f'--linux={linux}', - *(f"--{s.name.removeprefix('.')}={s.content}" - for s in uki.sections - if s.measure), + *to_measure, *(f'--bank={bank}' for bank in banks), # For measurement, the keys are not relevant, so we can lump all the phase paths @@ -542,10 +601,7 @@ def call_systemd_measure(uki, linux, opts): cmd = [ measure_tool, 'sign', - f'--linux={linux}', - *(f"--{s.name.removeprefix('.')}={s.content}" - for s in uki.sections - if s.measure), + *to_measure, *(f'--bank={bank}' for bank in banks), ] @@ -1431,6 +1487,14 @@ CONFIG_ITEMS = [ config_key = 'UKI/Extend', ), + ConfigItem( + '--measure-base', + metavar = 'UKI', + type = pathlib.Path, + help = 'path to existing UKI file whose relevant sections shall be used as base for PCR11 prediction', + config_key = 'UKI/MeasureBase', + ), + ConfigItem( '--pcr-banks', metavar = 'BANK…',