From: Luca Boccassi Date: Sat, 25 Jan 2025 02:09:49 +0000 (+0000) Subject: ukify: add --pcrsig and --join-pcrsig arguments to append offline signature X-Git-Tag: v258-rc1~1389^2~1 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=9876e88e23ad1ecbffd7c69b2e0a4cbff283f681;p=thirdparty%2Fsystemd.git ukify: add --pcrsig and --join-pcrsig arguments to append offline signature Add a build parameter to take an existing UKI and attach a .pcrsig section to it. This allows one to create a UKI with a .pcrpkey section with --policy-digest to get the json output from sd-measure, sign the digest offline, and attach the .pcrsig section with the signature later. --- diff --git a/man/ukify.xml b/man/ukify.xml index d6a46bc8438..f68ef0a8d0a 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -269,6 +269,20 @@ + + + + + takes a path to an existing PE file containing a + previously built UKI. takes a path to an existing pcrsig JSON blob, or + a verbatim inline blob. They must be used together, and without specifying any other UKI section + parameters. ukify will attach the pcrsig JSON blob to the UKI. This is useful + in combination with to create a UKI and then sign the TPM2 policy + digests offline. + + + + @@ -839,6 +853,56 @@ ID=factory-reset' \ The resulting UKI base-with-profile-0-1-2.efi will now contain three profiles. + + Offline signing of pcrsig section + + First, create a UKI and save the PCR JSON blob: + + $ 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' \ + --pcr-public-key=tpm2-pcr-public-key-initrd.pem \ + --policy-digest \ + --json=short \ + --output=base.efi >base.pcrs + + + Then, sign the PCR digests offline and insert them in the JSON blob: + + #!/usr/bin/python3 +import base64, json, subprocess + +priv_key = '/home/zbyszek/src/systemd/tpm2-pcr-private.pem' +base_file = 'base.pcrs' + +base = json.load(open(base_file)) + +for bank,policies in base.items(): + for policy in policies: + pol = base64.b16decode(policy['pol'].upper()) + call = subprocess.run(['openssl', 'dgst', f'-{bank}', '-sign', priv_key], + input=pol, + check=True, + capture_output=True) + sig = base64.b64encode(call.stdout).decode() + policy['sig'] = sig + +print(json.dumps(base)) + + + Finally, attach the updated JSON blob to the UKI: + + $ ukify build \ + --join-pcrsig=base.efi \ + --pcrsig=@base.pcrs \ + --json=short \ + --output=base-signed.efi + + + The resulting UKI base-signed.efi will now contain the signed PCR digests. + + diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index 0de7e8904db..8ae4d04e8db 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -891,5 +891,70 @@ def test_key_cert_generation(tmp_path): assert 'Certificate' in out assert re.search(r'Issuer: CN\s?=\s?SecureBoot signing key on host', out) +@pytest.mark.skipif(not slow_tests, reason='slow') +def test_join_pcrsig(capsys, kernel_initrd, tmp_path): + if kernel_initrd is None: + pytest.skip('linux+initrd not found') + try: + systemd_measure() + except ValueError: + pytest.skip('systemd-measure not found') + + ourdir = pathlib.Path(__file__).parent + pub = unbase64(ourdir / 'example.tpm2-pcr-public.pem.base64') + + output = tmp_path / 'basic.efi' + args = [ + 'build', + *kernel_initrd, + f'--output={output}', + f'--pcr-public-key={pub.name}', + '--json=short', + '--policy-digest', + ] + arg_tools + opts = ukify.parse_args(args) + try: + ukify.check_inputs(opts) + except OSError as e: + pytest.skip(str(e)) + + ukify.make_uki(opts) + pcrs = json.loads(capsys.readouterr().out) + for bank, sigs in pcrs.items(): + for sig in sigs: + sig['sig'] = 'a' * int(bank[3:]) + + opts = ukify.parse_args(['inspect', str(output)]) + ukify.inspect_sections(opts) + text = capsys.readouterr().out + assert re.search(r'\.pcrpkey', text, re.MULTILINE) + assert re.search(r'\.pcrsig', text, re.MULTILINE) + assert not re.search(r'"sig":', text, re.MULTILINE) + + output_sig = tmp_path / 'pcrsig.efi' + args = [ + 'build', + f'--output={output_sig}', + f'--join-pcrsig={output}', + f'--pcrsig={json.dumps(pcrs)}', + '--json=short', + ] + arg_tools + opts = ukify.parse_args(args) + try: + ukify.check_inputs(opts) + except OSError as e: + pytest.skip(str(e)) + + ukify.make_uki(opts) + + opts = ukify.parse_args(['inspect', str(output_sig)]) + ukify.inspect_sections(opts) + text = capsys.readouterr().out + assert re.search(r'\.pcrpkey', text, re.MULTILINE) + assert re.search(r'\.pcrsig', text, re.MULTILINE) + assert re.search(r'"sig":', text, re.MULTILINE) + + shutil.rmtree(tmp_path) + if __name__ == '__main__': sys.exit(pytest.main(sys.argv)) diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 639301bdb6b..af1b1b4a448 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -275,6 +275,8 @@ class UkifyConfig: pcr_private_keys: list[str] pcr_public_keys: list[str] pcrpkey: Optional[Path] + pcrsig: Union[str, Path, None] + join_pcrsig: Optional[Path] phase_path_groups: Optional[list[str]] policy_digest: bool profile: Union[str, Path, None] @@ -743,12 +745,13 @@ def pe_section_size(section: pefile.SectionStructure) -> int: return cast(int, min(section.Misc_VirtualSize, section.SizeOfRawData)) -def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> None: +def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> str: measure_tool = find_tool( 'systemd-measure', '/usr/lib/systemd/systemd-measure', opts=opts, ) + combined = '' banks = opts.pcr_banks or () @@ -784,6 +787,7 @@ def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> unique_to_measure[section.name] = section if opts.measure or opts.policy_digest: + pcrsigs = [] to_measure = unique_to_measure.copy() for dtbauto in dtbauto_to_measure: @@ -810,7 +814,22 @@ def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> cmd += [f'--public-key={opts.pcr_public_keys[0]}'] print('+', shell_join(cmd), file=sys.stderr) - subprocess.check_call(cmd) + output = subprocess.check_output(cmd, text=True) # type: ignore + + if opts.policy_digest: + pcrsig = json.loads(output) + pcrsigs += [pcrsig] + else: + print(output) + + if opts.policy_digest: + combined = combine_signatures(pcrsigs) + # We need to ensure the section has space for signatures, that will be added separately later, + # so add some whitespace to pad the section. At most we'll need 4kb per digest (rsa4096). + # We might even check the key type given we have it to know the precise length, but don't + # bother for now. + combined += ' ' * 1024 * combined.count('"pol":') + uki.add_section(Section.create('.pcrsig', combined)) # PCR signing @@ -848,13 +867,15 @@ def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) -> extra += [f'--phase={phase_path}' for phase_path in group or ()] print('+', shell_join(cmd + extra), file=sys.stderr) # type: ignore - pcrsig = subprocess.check_output(cmd + extra, text=True) # type: ignore - pcrsig = json.loads(pcrsig) + output = subprocess.check_output(cmd + extra, text=True) # type: ignore + pcrsig = json.loads(output) pcrsigs += [pcrsig] combined = combine_signatures(pcrsigs) uki.add_section(Section.create('.pcrsig', combined)) + return combined + def join_initrds(initrds: list[Path]) -> Union[Path, bytes, None]: if not initrds: @@ -885,7 +906,7 @@ class PEError(Exception): pass -def pe_add_sections(uki: UKI, output: str) -> None: +def pe_add_sections(opts: UkifyConfig, uki: UKI, output: str) -> None: pe = pefile.PE(uki.executable, fast_load=True) # Old stubs do not have the symbol/string table stripped, even though image files should not have one. @@ -936,10 +957,14 @@ def pe_add_sections(uki: UKI, output: str) -> None: if warnings: raise PEError(f'pefile warnings treated as errors: {warnings}') - security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY']] - if security.VirtualAddress != 0: - # We could strip the signatures, but why would anyone sign the stub? - raise PEError('Stub image is signed, refusing.') + # When attaching signatures we are operating on an existing UKI which might be signed + if not opts.pcrsig: + security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[ + pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_SECURITY'] + ] + if security.VirtualAddress != 0: + # 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) @@ -1005,6 +1030,47 @@ def pe_add_sections(uki: UKI, output: str) -> None: pe.__structures__.append(new_section) pe.sections.append(new_section) + # If there is a pre-signed JSON blob, we need to update the existing JSON, by appending the signature to + # each corresponding digest object. We have built the unsigned UKI with enough space to fit the .sig + # objects, so we can just replace the new signed JSON in the existing sections. + if opts.pcrsig: + signatures = json.loads(str(opts.pcrsig)) + for i, section in enumerate(pe.sections): + if pe_strip_section_name(section.Name) == '.pcrsig': + j = json.loads( + bytes( + pe.__data__[ + section.PointerToRawData : section.PointerToRawData + section.SizeOfRawData + ] + ) + .rstrip(b'\x00') + .decode() + ) + for (bank, sigs), (input_bank, input_sigs) in itertools.product( + j.items(), signatures.items() + ): + if input_bank != bank: + continue + for sig, input_sig in itertools.product(sigs, input_sigs): + if sig['pol'] == input_sig['pol']: + sig['sig'] = input_sig['sig'] + + encoded = json.dumps(j).encode() + if len(encoded) > section.SizeOfRawData: + raise PEError( + f'Not enough space in existing section .pcrsig of size {section.SizeOfRawData} to append new data of size {len(encoded)}.' # noqa: E501 + ) + + section.Misc_VirtualSize = len(encoded) + # bytes(n) results in an array of n zeroes + padding = bytes(section.SizeOfRawData - len(encoded)) + pe.__data__ = ( + pe.__data__[: section.PointerToRawData] + + encoded + + padding + + pe.__data__[section.PointerToRawData + section.SizeOfRawData :] + ) + pe.OPTIONAL_HEADER.CheckSum = 0 pe.OPTIONAL_HEADER.SizeOfImage = round_up( pe.sections[-1].VirtualAddress + pe.sections[-1].Misc_VirtualSize, @@ -1206,6 +1272,7 @@ def make_uki(opts: UkifyConfig) -> None: sign_args_present = opts.sb_key or opts.sb_cert_name sign_kernel = opts.sign_kernel linux = opts.linux + combined_sigs = '{}' if opts.linux and sign_args_present: assert opts.signtool is not None @@ -1224,7 +1291,7 @@ def make_uki(opts: UkifyConfig) -> None: print('Kernel version not specified, starting autodetection 😖.', file=sys.stderr) opts.uname = Uname.scrape(opts.linux, opts=opts) - uki = UKI(opts.stub) + uki = UKI(opts.join_pcrsig if opts.join_pcrsig else opts.stub) initrd = join_initrds(opts.initrd) pcrpkey: Union[bytes, Path, None] = opts.pcrpkey @@ -1288,7 +1355,7 @@ def make_uki(opts: UkifyConfig) -> None: uki.add_section(section) # Don't add a sbat section to profile PE binaries. - if opts.join_profiles or not opts.profile: + if (opts.join_profiles or not opts.profile) and not opts.pcrsig: if linux is not None: # Merge the .sbat sections from stub, kernel and parameter, so that revocation can be done on # either. @@ -1310,10 +1377,12 @@ def make_uki(opts: UkifyConfig) -> None: # PCR measurement and signing - if (opts.join_profiles or not opts.profile) and ( - not opts.sign_profiles or opts.profile in opts.sign_profiles + if ( + not opts.pcrsig + and (opts.join_profiles or not opts.profile) + and (not opts.sign_profiles or opts.profile in opts.sign_profiles) ): - call_systemd_measure(uki, opts=opts) + combined_sigs = call_systemd_measure(uki, opts=opts) # UKI profiles @@ -1370,7 +1439,9 @@ def make_uki(opts: UkifyConfig) -> None: print(f'Not signing expected PCR measurements for "{id}" profile') continue - call_systemd_measure(uki, opts=opts, profile_start=prev_len) + s = call_systemd_measure(uki, opts=opts, profile_start=prev_len) + if s: + combined_sigs = combine_signatures([json.loads(combined_sigs), json.loads(s)]) # UKI creation @@ -1380,7 +1451,7 @@ def make_uki(opts: UkifyConfig) -> None: else: unsigned_output = opts.output - pe_add_sections(uki, unsigned_output) + pe_add_sections(opts, uki, unsigned_output) # UKI signing @@ -1395,6 +1466,8 @@ def make_uki(opts: UkifyConfig) -> None: os.chmod(opts.output, 0o777 & ~umask) print(f'Wrote {"signed" if sign_args_present else "unsigned"} {opts.output}', file=sys.stderr) + if opts.policy_digest: + print(combined_sigs) @contextlib.contextmanager @@ -1896,6 +1969,17 @@ CONFIG_ITEMS = [ default=[], help='Which profiles to sign expected PCR measurements for', ), + ConfigItem( + '--pcrsig', + metavar='TEST|@PATH', + help='Signed PCR policy JSON [.pcrsig section] to append to an existing UKI', + config_key='UKI/PCRSig', + ), + ConfigItem( + '--join-pcrsig', + metavar='PATH', + help='A PE binary containing a UKI without a .pcrsig to join with --pcrsig', + ), ConfigItem( '--efi-arch', metavar='ARCH', @@ -2295,6 +2379,33 @@ def finalize_options(opts: argparse.Namespace) -> None: # one wasn't explicitly provided opts.profile = 'ID=main' + if opts.pcrsig and not opts.join_pcrsig: + raise ValueError('--pcrsig requires --join-pcrsig') + if opts.join_pcrsig and not opts.pcrsig: + raise ValueError('--join-pcrsig requires --pcrsig') + if opts.pcrsig and ( + opts.linux + or opts.initrd + or opts.profile + or opts.join_profiles + or opts.microcode + or opts.sbat + or opts.uname + or opts.os_release + or opts.cmdline + or opts.hwids + or opts.splash + or opts.devicetree + or opts.devicetree_auto + or opts.pcr_private_keys + or opts.pcr_public_keys + ): + raise ValueError('--pcrsig and --join-pcrsig cannot be used with other sections') + if opts.pcrsig: + opts.pcrsig = resolve_at_path(opts.pcrsig) + if isinstance(opts.pcrsig, Path): + opts.pcrsig = opts.pcrsig.read_text() + 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')