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.
This commit is contained in:
Luca Boccassi
2025-02-07 13:58:51 +00:00
committed by Luca Boccassi
parent c8c5ce5772
commit 9876e88e23
3 changed files with 256 additions and 16 deletions
+64
View File
@@ -269,6 +269,20 @@
<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>
+65
View File
@@ -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))
+127 -16
View File
@@ -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')