From 8cceaab8f5c597f207d5c86fcaa280d2e0e3a2b8 Mon Sep 17 00:00:00 2001 From: Luca Boccassi Date: Tue, 1 Sep 2026 19:50:31 +0100 Subject: [PATCH] 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 f0f368770005d0d7aeb9cef5883e0cb97b080848 --- man/ukify.xml | 12 ++++-- src/ukify/test/test_ukify.py | 55 ++++++++++++++++++++----- src/ukify/ukify.py | 78 ++++++++++++++++++++---------------- 3 files changed, 96 insertions(+), 49 deletions(-) diff --git a/man/ukify.xml b/man/ukify.xml index b1f39d2494..5f2c86a2cd 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -249,7 +249,8 @@ systemd-cryptenroll1, by using the option. The extra signatures reuse the first PCRPrivateKey=/, hence one must be - specified. Defaults to false. + specified, unless is used to generate the policies for offline + signing. Defaults to false. @@ -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 @@ -996,7 +998,7 @@ ID=factory-reset' \ Then, sign the PCR digests offline and insert them in the JSON blob: #!/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() diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index fbd70386a8..5e34e2da3e 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -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 diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 9bfa1ecb78..fd00a7cbb4 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -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)