measure: expose offline signing data in policy-digest output

The PolicyPCR digest is not always the complete input to an offline
signature. PolicyAuthorize also includes the hashed policy reference.
This is now required for NVPCRs to work in the initrd.

Expose the complete data as "tbs" (to-be-signed) and use it when ukify
matches offline signatures. Keep "pol" as the compatibility fallback.

Fixes: https://github.com/systemd/systemd/issues/43635

Follow-up for f0f3687700
This commit is contained in:
Luca Boccassi
2026-09-07 16:15:42 +01:00
parent 7c447814ca
commit 548f87538a
6 changed files with 81 additions and 37 deletions
+12 -8
View File
@@ -113,6 +113,10 @@
TPM2 policy and print its digest. This will write a JSON object to standard output that contains
the policy digests for all specified PCR banks (see the <option>--bank=</option> option below),
so that it may be signed offline, for the cases where the private key is not directly accessible.
Each policy contains a <literal>pol</literal> field with the approved PolicyPCR digest and a
<literal>tbs</literal> field with the data to sign. The two fields have the same value when no
policy reference is specified. With <option>--policyref=</option>, <literal>tbs</literal> is the
concatenation of <literal>pol</literal> and the digest of the policy reference.
If <option>--public-key=</option> or <option>--certificate=</option> are specified, the JSON object
will also contain the key fingerprint.</para>
@@ -280,14 +284,14 @@
<varlistentry>
<term><option>--policyref=<replaceable>STRING</replaceable></option></term>
<listitem><para>When generating a PCR JSON signature (via the <command>sign</command> command), bind
the generated signatures to the specified policy reference. The policy reference is an opaque string
that is included in the data that is signed, and is also incorporated into the authorization policy of
any TPM2 resource that is locked against these signatures (via the
<constant>TPM2_PolicyAuthorize</constant> assertion). As a result, a signed policy is only accepted
for a resource whose authorization policy was created with the same policy reference. Signed policies
created with a different policy reference are rejected, even if they are produced with the same
signing key.</para>
<listitem><para>When generating a PCR JSON signature or policy digest (via the
<command>sign</command> or <command>policy-digest</command> command), bind the generated signatures
to the specified policy reference. The policy reference is an opaque string that is included in the
data that is signed, and is also incorporated into the authorization policy of any TPM2 resource that
is locked against these signatures (via the <constant>TPM2_PolicyAuthorize</constant> assertion). As
a result, a signed policy is only accepted for a resource whose authorization policy was created with
the same policy reference. Signed policies created with a different policy reference are rejected,
even if they are produced with the same signing key.</para>
<para>This can be used together with <option>--phase=</option> and <option>--append=</option> to bind
signatures for different boot phases to different policy references, so that a single signing key can
+4 -6
View File
@@ -998,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, hashlib, json, subprocess
import base64, json, subprocess
priv_key = '/home/zbyszek/src/systemd/tpm2-pcr-private.pem'
base_file = 'base.pcrs'
@@ -1007,11 +1007,9 @@ base = json.load(open(base_file))
for bank,policies in base.items():
for policy in policies:
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=to_sign,
tbs = base64.b16decode(policy['tbs'].upper())
call = subprocess.run(['openssl', 'dgst', '-sha256', '-sign', priv_key],
input=tbs,
check=True,
capture_output=True)
sig = base64.b64encode(call.stdout).decode()
+11 -10
View File
@@ -1009,17 +1009,17 @@ static int build_policy_digest(bool sign) {
if (r < 0)
return log_error_errno(r, "Could not calculate PolicyPCR digest: %m");
_cleanup_(iovec_done) struct iovec tbs_data = {};
r = tpm2_make_policy_authorize_tbs_data(&pcr_policy_digest, arg_policyref, &tbs_data);
if (r < 0)
return r;
_cleanup_free_ void *sig = NULL;
size_t ss = 0;
if (privkey) {
/* We always use SHA256 for signing currently. Regardless of the bank. */
const EVP_MD *sha256 = ASSERT_PTR(sym_EVP_get_digestbyname("sha256"));
_cleanup_(iovec_done) struct iovec tbs_data = {};
r = tpm2_make_policy_authorize_tbs_data(&pcr_policy_digest, arg_policyref, &tbs_data);
if (r < 0)
return r;
r = digest_and_sign(sha256, privkey, tbs_data.iov_base, tbs_data.iov_len, &sig, &ss);
if (r == -EADDRNOTAVAIL)
return log_error_errno(r, "Hash algorithm '%s' not available while signing. (Maybe OS security policy disables this algorithm?)", sym_EVP_MD_get0_name(p->md));
@@ -1042,11 +1042,12 @@ static int build_policy_digest(bool sign) {
_cleanup_(sd_json_variant_unrefp) sd_json_variant *bv = NULL;
r = sd_json_buildo(&bv,
SD_JSON_BUILD_PAIR_VARIANT("pcrs", a), /* PCR mask */
SD_JSON_BUILD_PAIR_CONDITION(pubkey_fp_size > 0, "pkfp", SD_JSON_BUILD_HEX(pubkey_fp, pubkey_fp_size)), /* SHA256 fingerprint of public key (DER) used for the signature */
SD_JSON_BUILD_PAIR_CONDITION(!isempty(arg_policyref), "ref", SD_JSON_BUILD_STRING(arg_policyref)), /* TPM2 policy reference */
SD_JSON_BUILD_PAIR_HEX("pol", pcr_policy_digest.buffer, pcr_policy_digest.size), /* TPM2 policy hash that is signed */
SD_JSON_BUILD_PAIR_CONDITION(ss > 0, "sig", SD_JSON_BUILD_BASE64(sig, ss))); /* signature data */
SD_JSON_BUILD_PAIR_VARIANT("pcrs", a), /* PCR mask */
SD_JSON_BUILD_PAIR_CONDITION(pubkey_fp_size > 0, "pkfp", SD_JSON_BUILD_HEX(pubkey_fp, pubkey_fp_size)), /* SHA256 fingerprint of public key (DER) used for the signature */
SD_JSON_BUILD_PAIR_CONDITION(!isempty(arg_policyref), "ref", SD_JSON_BUILD_STRING(arg_policyref)), /* TPM2 policy reference */
SD_JSON_BUILD_PAIR_HEX("pol", pcr_policy_digest.buffer, pcr_policy_digest.size), /* TPM2 PolicyPCR digest */
SD_JSON_BUILD_PAIR_CONDITION(!sign, "tbs", SD_JSON_BUILD_HEX(tbs_data.iov_base, tbs_data.iov_len)), /* data to sign offline */
SD_JSON_BUILD_PAIR_CONDITION(ss > 0, "sig", SD_JSON_BUILD_BASE64(sig, ss))); /* signature data */
if (r < 0)
return log_error_errno(r, "Failed to build JSON object: %m");
+28
View File
@@ -1328,6 +1328,34 @@ def test_key_cert_generation_common_name_too_long(tmp_path):
ukify.generate_keys(opts)
def test_apply_pcr_signatures() -> None:
pcrsig = {
'sha256': [
{'pol': 'same', 'tbs': 'first'},
{'pol': 'same', 'tbs': 'second'},
{'pol': 'legacy', 'ref': 'first'},
{'pol': 'legacy', 'ref': 'second'},
]
}
signatures = {
'sha256': [
{'pol': 'same', 'tbs': 'second', 'sig': 'second-signature'},
{'pol': 'same', 'tbs': 'first', 'sig': 'first-signature'},
{'pol': 'legacy', 'ref': 'second', 'sig': 'legacy-second-signature'},
{'pol': 'legacy', 'ref': 'first', 'sig': 'legacy-first-signature'},
]
}
ukify.apply_pcr_signatures(pcrsig, signatures)
assert [policy['sig'] for policy in pcrsig['sha256']] == [
'first-signature',
'second-signature',
'legacy-first-signature',
'legacy-second-signature',
]
@pytest.mark.skipif(not slow_tests, reason='slow')
def test_join_pcrsig(capsys, kernel_initrd, tmp_path):
if kernel_initrd is None:
+20 -8
View File
@@ -749,6 +749,25 @@ def combine_signatures(pcrsigs: list[dict[str, str]]) -> str:
return json.dumps(combined)
def apply_pcr_signatures(pcrsig: dict[str, Any], signatures: dict[str, Any]) -> None:
for (bank, policies), (input_bank, input_policies) in itertools.product(
pcrsig.items(), signatures.items()
):
if input_bank != bank:
continue
for policy, input_policy in itertools.product(policies, input_policies):
if 'tbs' in policy and 'tbs' in input_policy:
match = policy['tbs'] == input_policy['tbs']
else:
match = (policy['pol'], policy.get('ref')) == (
input_policy['pol'],
input_policy.get('ref'),
)
if match:
policy['sig'] = input_policy['sig']
def key_path_groups(
opts: UkifyConfig,
include_extra: bool,
@@ -1136,14 +1155,7 @@ def pe_add_sections(opts: UkifyConfig, uki: UKI, output: str) -> None:
.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'], sig.get('ref')) == (input_sig['pol'], input_sig.get('ref')):
sig['sig'] = input_sig['sig']
apply_pcr_signatures(j, signatures)
encoded = json.dumps(j).encode()
if len(encoded) > section.SizeOfRawData:
+6 -5
View File
@@ -83,17 +83,18 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "/tmp/pcrsign-
openssl rsa -pubout -in "/tmp/pcrsign-private.pem" -out "/tmp/pcrsign-public.pem"
# Verify that the offline signature obtained via policy-digests is the same as an online signature created
# with the same key by systemd-measure
digest="$("$SD_MEASURE" policy-digest --json=short --linux=/tmp/tpmdata1 --initrd=/tmp/tpmdata2 --bank=sha256 --public-key="/tmp/pcrsign-public.pem" --phase=:)"
signed_digest="$("$SD_MEASURE" sign --json=short --linux=/tmp/tpmdata1 --initrd=/tmp/tpmdata2 --bank=sha256 --private-key="/tmp/pcrsign-private.pem" --public-key="/tmp/pcrsign-public.pem" --phase=:)"
echo "$digest" | jq -r '.sha256 | to_entries[] | .value.pol' | while read -r pol; do
# with the same key and policy reference by systemd-measure
digest="$("$SD_MEASURE" policy-digest --json=short --linux=/tmp/tpmdata1 --initrd=/tmp/tpmdata2 --bank=sha256 --public-key="/tmp/pcrsign-public.pem" --phase=: --policyref=foo)"
signed_digest="$("$SD_MEASURE" sign --json=short --linux=/tmp/tpmdata1 --initrd=/tmp/tpmdata2 --bank=sha256 --private-key="/tmp/pcrsign-private.pem" --public-key="/tmp/pcrsign-public.pem" --phase=: --policyref=foo)"
echo "$digest" | jq -r '.sha256 | to_entries[] | [.value.pol, .value.tbs] | @tsv' | while read -r pol tbs; do
# Note: basenc before coreutils 9.5 refuses lowercase hex input strings
offline_sig="$(echo -n "$pol" | \
offline_sig="$(echo -n "$tbs" | \
tr '[:lower:]' '[:upper:]' | \
basenc --base16 --decode | \
openssl dgst -sign /tmp/pcrsign-private.pem -sha256 | \
base64 -w0)"
online_sig="$(echo "$signed_digest" | jq -r --arg pol "$pol" '.sha256[] | select(.pol == $pol) | .sig')"
test "$tbs" != "$pol"
test -n "$offline_sig"
test -n "$online_sig"
test "$offline_sig" = "$online_sig"