From 548f87538a82447237e660c3f93908a7c490119c Mon Sep 17 00:00:00 2001 From: Luca Boccassi Date: Thu, 3 Sep 2026 15:27:41 +0100 Subject: [PATCH] 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 f0f368770005d0d7aeb9cef5883e0cb97b080848 --- man/systemd-measure.xml | 20 ++++++++++++-------- man/ukify.xml | 10 ++++------ src/measure/measure-tool.c | 21 +++++++++++---------- src/ukify/test/test_ukify.py | 28 ++++++++++++++++++++++++++++ src/ukify/ukify.py | 28 ++++++++++++++++++++-------- test/units/TEST-70-TPM2.measure.sh | 11 ++++++----- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/man/systemd-measure.xml b/man/systemd-measure.xml index a81b311bda..d5a2f4120a 100644 --- a/man/systemd-measure.xml +++ b/man/systemd-measure.xml @@ -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 below), so that it may be signed offline, for the cases where the private key is not directly accessible. + Each policy contains a pol field with the approved PolicyPCR digest and a + tbs field with the data to sign. The two fields have the same value when no + policy reference is specified. With , tbs is the + concatenation of pol and the digest of the policy reference. If or are specified, the JSON object will also contain the key fingerprint. @@ -280,14 +284,14 @@ - When generating a PCR JSON signature (via the sign 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 - TPM2_PolicyAuthorize 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. + When generating a PCR JSON signature or policy digest (via the + sign or policy-digest 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 TPM2_PolicyAuthorize 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. This can be used together with and to bind signatures for different boot phases to different policy references, so that a single signing key can diff --git a/man/ukify.xml b/man/ukify.xml index 5f2c86a2cd..cf30ac937f 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -998,7 +998,7 @@ ID=factory-reset' \ Then, sign the PCR digests offline and insert them in the JSON blob: #!/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() diff --git a/src/measure/measure-tool.c b/src/measure/measure-tool.c index 6a0b40d53e..9420ea05be 100644 --- a/src/measure/measure-tool.c +++ b/src/measure/measure-tool.c @@ -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"); diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index 5e34e2da3e..b1bb38203e 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -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: diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index fd00a7cbb4..fa69057a1a 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -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: diff --git a/test/units/TEST-70-TPM2.measure.sh b/test/units/TEST-70-TPM2.measure.sh index f74087544e..9bbf254cc6 100755 --- a/test/units/TEST-70-TPM2.measure.sh +++ b/test/units/TEST-70-TPM2.measure.sh @@ -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"