]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
ukify: add --pcrsig and --join-pcrsig arguments to append offline signature
authorLuca Boccassi <luca.boccassi@gmail.com>
Sat, 25 Jan 2025 02:09:49 +0000 (02:09 +0000)
committerLuca Boccassi <bluca@debian.org>
Fri, 7 Feb 2025 13:58:51 +0000 (13:58 +0000)
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.

man/ukify.xml
src/ukify/test/test_ukify.py
src/ukify/ukify.py

index d6a46bc8438b7b7cfdad7a09ce9d360b9c3397e4..f68ef0a8d0a00a3ddb941abcbf58f36a350d26a6 100644 (file)
           <xi:include href="version-info.xml" xpointer="v258"/></listitem>
         </varlistentry>
 
+        <varlistentry>
+          <term><option>--join-pcrsig=<replaceable>PATH</replaceable></option></term>
+          <term><option>--pcrsig=<replaceable>TEXT</replaceable>|<replaceable>@PATH</replaceable></option></term>
+
+          <listitem><para><option>--join-pcrsig=</option> takes a path to an existing PE file containing a
+          previously built UKI. <option>--pcrsig=</option> 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. <command>ukify</command> will attach the pcrsig JSON blob to the UKI. This is useful
+          in combination with <option>--policy-digest</option> to create a UKI and then sign the TPM2 policy
+          digests offline.</para>
+
+          <xi:include href="version-info.xml" xpointer="v258"/></listitem>
+        </varlistentry>
+
         <varlistentry>
           <term><option>--tools=<replaceable>DIRS</replaceable></option></term>
 
@@ -839,6 +853,56 @@ ID=factory-reset' \
       <para>The resulting UKI <filename>base-with-profile-0-1-2.efi</filename> will now contain three profiles.</para>
     </example>
 
+    <example>
+      <title>Offline signing of pcrsig section</title>
+
+      <para>First, create a UKI and save the PCR JSON blob:</para>
+
+      <programlisting>$ 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
+</programlisting>
+
+      <para>Then, sign the PCR digests offline and insert them in the JSON blob:</para>
+
+      <programlisting>#!/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))
+</programlisting>
+
+      <para>Finally, attach the updated JSON blob to the UKI:</para>
+
+      <programlisting>$ ukify build \
+      --join-pcrsig=base.efi \
+      --pcrsig=@base.pcrs \
+      --json=short \
+      --output=base-signed.efi
+</programlisting>
+
+      <para>The resulting UKI <filename>base-signed.efi</filename> will now contain the signed PCR digests.</para>
+    </example>
+
   </refsect1>
 
   <refsect1>
index 0de7e8904dbd2fd75514ea26b3a178f2ebd6cd76..8ae4d04e8db0502b176d6662637cf4dfbdd6959c 100755 (executable)
@@ -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))
index 639301bdb6bd7f82f69b2c634240f5a3720374f7..af1b1b4a448da33e9c661a53228f058aadc88884 100755 (executable)
@@ -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')