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:
committed by
Luca Boccassi
parent
c8c5ce5772
commit
9876e88e23
@@ -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>
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user