ukify: generate initrd policy digests for offline signing

When --sign-initrd-pcrs is used with --policy-digest, generate the
extra enter-initrd policy with the "initrd" policy reference. This
matches the policy produced when ukify signs with a private key.

Fixes building UKIs with offline signing (e.g.: on OBS) and using
NVPCRs.

Follow-up for f0f3687700
This commit is contained in:
Luca Boccassi
2026-09-03 12:55:15 +02:00
committed by Zbigniew Jędrzejewski-Szmek
parent 786fcb493f
commit 8cceaab8f5
3 changed files with 96 additions and 49 deletions
+8 -4
View File
@@ -249,7 +249,8 @@
<citerefentry><refentrytitle>systemd-cryptenroll</refentrytitle><manvolnum>1</manvolnum></citerefentry>,
by using the <option>--tpm2-public-key-policyref=initrd</option> option. The extra signatures reuse
the first <varname>PCRPrivateKey=</varname>/<option>--pcr-private-key=</option>, hence one must be
specified. Defaults to false.</para>
specified, unless <option>--policy-digest</option> is used to generate the policies for offline
signing. Defaults to false.</para>
<xi:include href="version-info.xml" xpointer="v262"/></listitem>
</varlistentry>
@@ -989,6 +990,7 @@ ID=factory-reset' \
--cmdline='quiet rw' \
--pcr-public-key=tpm2-pcr-initrd-public-key.pem \
--policy-digest \
--sign-initrd-pcrs \
--json=short \
--output=base.efi >base.pcrs
</programlisting>
@@ -996,7 +998,7 @@ ID=factory-reset' \
<para>Then, sign the PCR digests offline and insert them in the JSON blob:</para>
<programlisting>#!/usr/bin/python3
import base64, json, subprocess
import base64, hashlib, json, subprocess
priv_key = '/home/zbyszek/src/systemd/tpm2-pcr-private.pem'
base_file = 'base.pcrs'
@@ -1005,9 +1007,11 @@ base = json.load(open(base_file))
for bank,policies in base.items():
for policy in policies:
pol = base64.b16decode(policy['pol'].upper())
to_sign = base64.b16decode(policy['pol'].upper())
if policyref := policy.get('ref'):
to_sign += hashlib.sha256(policyref.encode()).digest()
call = subprocess.run(['openssl', 'dgst', f'-{bank}', '-sign', priv_key],
input=pol,
input=to_sign,
check=True,
capture_output=True)
sig = base64.b64encode(call.stdout).decode()
+45 -10
View File
@@ -299,6 +299,26 @@ def test_parse_args_many():
assert opts.sign_initrd_pcrs is False
@pytest.mark.parametrize(
'signing_args',
(
('--pcr-private-key=PKEY',),
('--policy-digest', '--pcr-public-key=PKEY'),
),
ids=('private-key', 'policy-digest'),
)
def test_parse_args_sign_initrd_pcrs(signing_args):
args = ['build', '--linux=LINUX', '--output=OUTPUT', '--sign-initrd-pcrs']
opts = ukify.parse_args([*args, *signing_args])
assert opts.sign_initrd_pcrs is True
with pytest.raises(
ValueError,
match='--sign-initrd-pcrs requires --pcr-private-key= or --policy-digest',
):
ukify.parse_args(args)
def test_parse_sections():
opts = ukify.parse_args(
[
@@ -1129,7 +1149,9 @@ def test_pcr_signing3(kernel_initrd, tmp_path):
shutil.rmtree(tmp_path)
def test_pcr_signing_initrd_pcrs(kernel_initrd, tmp_path):
@pytest.mark.skipif(not slow_tests, reason='slow')
@pytest.mark.parametrize('policy_digest', (False, True), ids=('sign', 'policy-digest'))
def test_pcr_signing_initrd_pcrs(kernel_initrd, tmp_path, policy_digest):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
try:
@@ -1150,7 +1172,7 @@ def test_pcr_signing_initrd_pcrs(kernel_initrd, tmp_path):
'--cmdline=ARG1 ARG2 ARG3',
'--os-release=ID=foobar\n',
'--pcr-banks=sha384', # sha1 might not be allowed, use something else
f'--pcr-private-key={priv.name}',
*(['--policy-digest'] if policy_digest else [f'--pcr-private-key={priv.name}']),
f'--pcr-public-key={pub.name}',
'--sign-initrd-pcrs',
] + arg_tools
@@ -1170,13 +1192,15 @@ def test_pcr_signing_initrd_pcrs(kernel_initrd, tmp_path):
sig = json.loads(open(tmp_path / 'out.pcrsig').read())
assert list(sig.keys()) == ['sha384']
assert len(sig['sha384']) == 5 # five items for five phase paths
assert 'ref' not in sig['sha384'][0]
assert 'ref' not in sig['sha384'][1]
assert 'ref' not in sig['sha384'][2]
assert 'ref' not in sig['sha384'][3]
assert 'ref' in sig['sha384'][4]
assert sig['sha384'][4]['ref'] == 'initrd'
policies = sig['sha384']
assert len(policies) == 5 # five items for five phase paths
assert policies[0]['pol'] == policies[4]['pol']
assert all('ref' not in policy for policy in policies[:4])
assert policies[4]['ref'] == 'initrd'
if policy_digest:
assert all('pkfp' in policy and 'sig' not in policy for policy in policies)
else:
assert all('sig' in policy for policy in policies)
shutil.rmtree(tmp_path)
@@ -1324,6 +1348,7 @@ def test_join_pcrsig(capsys, kernel_initrd, tmp_path):
f'--pcr-public-key={pub.name}',
'--json=short',
'--policy-digest',
'--sign-initrd-pcrs',
] + arg_tools
opts = ukify.parse_args(args)
try:
@@ -1335,7 +1360,7 @@ def test_join_pcrsig(capsys, kernel_initrd, tmp_path):
pcrs = json.loads(capsys.readouterr().out)
for bank, sigs in pcrs.items():
for sig in sigs:
sig['sig'] = 'a' * int(bank[3:])
sig['sig'] = ('b' if sig.get('ref') else 'a') * int(bank[3:])
opts = ukify.parse_args(['inspect', str(output)])
ukify.inspect_sections(opts)
@@ -1360,6 +1385,16 @@ def test_join_pcrsig(capsys, kernel_initrd, tmp_path):
ukify.make_uki(opts)
pcrsig = tmp_path / 'out.pcrsig'
subprocess.check_call(
['objcopy', f'--dump-section=.pcrsig={pcrsig}', output_sig, tmp_path / 'dummy'],
text=True,
)
attached = json.loads(pcrsig.read_text())
for bank, sigs in attached.items():
for sig in sigs:
assert sig['sig'] == ('b' if sig.get('ref') else 'a') * int(bank[3:])
opts = ukify.parse_args(['inspect', str(output_sig)])
ukify.inspect_sections(opts)
text = capsys.readouterr().out
+43 -35
View File
@@ -83,6 +83,7 @@ DEFAULT_CONFIG_FILE = 'ukify.conf'
# https://datatracker.ietf.org/doc/html/rfc5280 ub-common-name; bytes, not chars
COMMON_NAME_MAX_LEN = 64
INITRD_PCR_POLICY: tuple[list[str], str] = (['enter-initrd'], 'initrd')
class Style:
@@ -771,14 +772,13 @@ def key_path_groups(
)
# When requested, emit an extra signing group that reuses the first PCR signing key to produce
# a signed PCR policyy that can only be used from the initrd.
# a signed PCR policy that can only be used from the initrd.
if opts.sign_initrd_pcrs and include_extra:
yield (
opts.pcr_private_keys[0],
pub_keys[0] if pub_keys else None,
certs[0] if certs else None,
['enter-initrd'],
'initrd',
*INITRD_PCR_POLICY,
)
@@ -839,39 +839,47 @@ def call_systemd_measure(uki: UKI, opts: UkifyConfig, profile_start: int = 0) ->
if dtbauto is not None:
to_measure[dtbauto.name] = dtbauto
pp_groups = opts.phase_path_groups or []
cmd = [
measure_tool,
'calculate' if opts.measure else 'policy-digest',
'--json',
opts.json,
*(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
*(f'--bank={bank}' for bank in banks),
# For measurement, the keys are not relevant, so we can lump all the phase paths
# into one call to systemd-measure calculate.
*(f'--phase={phase_path}' for phase_path in itertools.chain.from_iterable(pp_groups)),
policy_groups: list[tuple[list[str], str]] = [
(list(itertools.chain.from_iterable(opts.phase_path_groups or [])), ''),
]
if opts.policy_digest and opts.sign_initrd_pcrs:
policy_groups += [INITRD_PCR_POLICY]
# The JSON object will be used for offline signing, include the public key
# so that the fingerprint is included too. In case a certificate is passed, use the
# right parameter so that systemd-measure can extract the public key from it.
if opts.policy_digest:
if opts.pcr_public_keys:
cmd += ['--public-key', opts.pcr_public_keys[0]]
elif opts.pcr_certificates:
cmd += ['--certificate', opts.pcr_certificates[0]]
if opts.certificate_provider:
cmd += ['--certificate-source', f'provider:{opts.certificate_provider}']
for phase_paths, policyref in policy_groups:
cmd = [
measure_tool,
'calculate' if opts.measure else 'policy-digest',
'--json',
opts.json,
*(f'--{s.name.removeprefix(".")}={s.content}' for s in to_measure.values()),
*(f'--bank={bank}' for bank in banks),
# For measurement, the keys are not relevant, so we can lump all the phase paths
# into one call to systemd-measure calculate.
*(f'--phase={phase_path}' for phase_path in phase_paths),
]
print('+', shell_join(cmd), file=sys.stderr)
output = subprocess.check_output(cmd, text=True) # type: ignore
# The JSON object will be used for offline signing, include the public key
# so that the fingerprint is included too. In case a certificate is passed, use the
# right parameter so that systemd-measure can extract the public key from it.
if opts.policy_digest:
if opts.pcr_public_keys:
cmd += [f'--public-key={opts.pcr_public_keys[0]}']
elif opts.pcr_certificates:
cmd += [f'--certificate={opts.pcr_certificates[0]}']
if opts.certificate_provider:
cmd += [f'--certificate-source=provider:{opts.certificate_provider}']
if opts.policy_digest:
pcrsig = json.loads(output)
pcrsigs += [pcrsig]
else:
print(output)
if policyref:
cmd += [f'--policyref={policyref}']
print('+', shell_join(cmd), file=sys.stderr)
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)
@@ -1134,7 +1142,7 @@ def pe_add_sections(opts: UkifyConfig, uki: UKI, output: str) -> None:
if input_bank != bank:
continue
for sig, input_sig in itertools.product(sigs, input_sigs):
if sig['pol'] == input_sig['pol']:
if (sig['pol'], sig.get('ref')) == (input_sig['pol'], input_sig.get('ref')):
sig['sig'] = input_sig['sig']
encoded = json.dumps(j).encode()
@@ -2528,8 +2536,8 @@ def finalize_options(opts: argparse.Namespace) -> None:
raise ValueError('--phases= specifications must match --pcr-private-key=')
if n_policyrefs is not None and n_policyrefs != n_pcr_priv:
raise ValueError('--policyref= specifications must match --pcr-private-key=')
if opts.sign_initrd_pcrs and not n_pcr_priv:
raise ValueError('--sign-initrd-pcrs requires at least one --pcr-private-key=')
if opts.sign_initrd_pcrs and not (n_pcr_priv or opts.policy_digest):
raise ValueError('--sign-initrd-pcrs requires --pcr-private-key= or --policy-digest')
opts.cmdline = resolve_at_path(opts.cmdline)