diff --git a/man/ukify.xml b/man/ukify.xml index bfe54a8f27..983e89c270 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -228,6 +228,31 @@ + + + + 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. + + + + + + + + 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. + + + + @@ -400,6 +425,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 @@ -692,6 +728,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 3478ff5689..5057d72d2c 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 @@ -380,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] @@ -492,7 +500,19 @@ 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): + + 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) @@ -501,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 @@ -530,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), ] @@ -631,6 +699,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()) @@ -664,8 +735,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.') @@ -701,7 +772,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.") @@ -783,6 +854,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 @@ -847,8 +940,12 @@ 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 ), ('.osrel', opts.os_release, True ), ('.cmdline', opts.cmdline, True ), ('.dtb', opts.devicetree, True ), @@ -870,21 +967,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 @@ -1041,7 +1139,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) @@ -1359,6 +1457,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', @@ -1374,6 +1479,22 @@ 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( + '--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…', @@ -1678,7 +1799,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')