Files
systemd/test/units/TEST-70-TPM2.nvpcr.sh
T
Chris Coulson f0f3687700 tpm2: Improve how NvPCR protection works.
NV indexes created in the storage hierarchy can be undefined and
redefined with TPM owner auth. Because of this, NvPCRs need some way
to prevent them from being redfined in a way that allows spoof
measurements to be replayed.

The current approach requires knowledge of a secret ("anchor secret")
in order to derive the initial NvPCR measurement and to derive a
measurement to an existing PCR (9). The credential is protected by the
TPM with a PCR policy. Without access to the credential, it's not
possible to replay measurements to a newly defined NvPCR without
breaking the binding with the measurement in PCR 9. However, this
approach has a couple of issues:

- The credential is currently only protected by PCR11. As it's not
  protected by the rest of the boot chain, it's possible to boot other
  operating systems in order to replay the PCR11 measurements and
  recover the secret. Note that as the NvPCR anchoring happens in early
  boot, the credential is stored in the ESP.
- Someone with privileged access to a system can just create a new
  credential containing a known secret and store this in /var/lib and
  the ESP. The NvPCRs are anchored with this known secret on subsequent
  boots, and therefore the measurements can no longer be trusted.
  Imagine the scenario where privileged access is theoretically possible
  as a result of some vulnerability. After upgrading the system to fix
  this vulnerability, the system should be able to attest that it is
  now in a good state. However, if an adversary were able to use their
  priviliges to replace the credential, they are able to obtain
  persistence and the NvPCR measurements are no longer trustworthy.

This PR changes things to take a different approach. Instead of
requiring knowledge of a secret, the NvPCRs are now created in a way
that requires a policy to be satisfied for writing. The write policy has
2 branches:
- TPM2_PolicyNvWritten(true), which can be satisified without any
  further authorization if the NvPCR has already been extended.
- TPM2_PolicyAuthorize(pcrPubKey, SHA256("nvpcr-init")) which can be
  satisfied with a signed PCR policy, and must be used to perform the
  initial extend to a NvPCR.

The intention here is that the signed PCR policy that can be used to
authorize the initial extend to the NvPCR can only be satisfied during
early boot. During later boot phases, this signed PCR policy must not be
valid. This means that if a NvPCR is undefined and redefined, it won't
be possible to satisfy its write policy in order to able to perform the
initial extend.

In order to anchor the NvPCRs and prevent them from being undefined and
then redefined with a different policy that does allow them to be
extended, the names of the NvPCRs are measured to PCR9. Verifiers must
check that the names of attested NvPCRs match the measurements in PCR9.

This uses the PCR signing key from the currently booted UKI to create
the NvPCRs. If this changes between boots, then tpm2-setup automatically
recreates new NvPCRs with an updated write policy to reflect this. I've
tried to be careful to not undefine arbitrary NV indexes in this case,
so it checks that the existing NV index looks like a NvPCR (ie, it has
the expected attributes) before undefining it.

I did originally try to preserve the old behaviour for existing systems,
but it makes things a lot more complicated. As the new implementation
already creates new NvPCRs when the PCR signing key changes, I ended up
just automatically upgrading the old NvPCRs as well. Again, I check here
that any existing NV index looks like an old style NvPCR (ie, it has the
expected attributes) before undefining it.

I did notice that the initial NvPCR measurement isn't going into the
log. I don't know if that was an intentional choice, but I've preserved
that behaviour in this PR.

This also adds a new option to ukify (--sign-initrd-pcrs) which creates
signed policies (one per PCR bank) that can only be satisfied from the
initrd. These policies are used for initializing the NvPCRs, but can also
be used for protecting TPM2 keyslots enrolled with systemd-cryptenroll
(by using the --tpm2-public-key-policyref=initrd option).

There is one outstanding issue. The NvPCR definitions support different
algorithms, but the use of PolicyAuthorize means that they can only support
SHA-256 for now. This is because the signed policy algorithm must match
the name algorithm, and some additional work is required to support
signed PCR policies for algorithms other than SHA256. I've left a note in
tpm2_nvpcr_initialize that details what's required, and I'll take a look
at that in a subsequent PR.
2026-07-28 08:35:39 +02:00

262 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: LGPL-2.1-or-later
set -eux
set -o pipefail
# shellcheck source=test/units/util.sh
. "$(dirname "$0")"/util.sh
export SYSTEMD_LOG_LEVEL=debug
SD_PCREXTEND="/usr/lib/systemd/systemd-pcrextend"
SD_TPM2SETUP="/usr/lib/systemd/systemd-tpm2-setup"
SD_MEASURE="/usr/lib/systemd/systemd-measure"
if [[ ! -x "${SD_PCREXTEND:?}" ]] || [[ ! -x "${SD_MEASURE:?}" ]] || ! tpm_has_pcr sha256 11; then
echo "$SD_PCREXTEND, $SD_MEASURE or PCR sysfs files not found, skipping PCR extension tests"
exit 0
fi
at_exit() {
if [[ $? -ne 0 ]]; then
# Dump the event log on fail, to make debugging a bit easier
jq --seq --slurp </run/log/systemd/tpm2-measure.log
fi
mv -f /run/systemd/tpm2-pcr-public-key.pem.bak /run/systemd/tpm2-pcr-public-key.pem
mv -f /run/systemd/tpm2-pcr-signature.json.bak /run/systemd/tpm2-pcr-signature.json
rm -f /tmp/tpm2-pcr-private-key.pem
rm -rf /run/nvpcr /tmp/nvpcr
rm -f /var/tmp/nvpcr.raw /run/verity.d/test-70-nvpcr.crt
rm -f /run/systemd/nvpcr/test.auth /run/systemd/nvpcr/test2.auth /run/systemd/nvpcr/aaa.auth /run/systemd/nvpcr/zzz.auth
rm -f /tmp/test.policy
}
trap at_exit EXIT
# systemd-tpm2-setup returns EX_UNAVAILABLE rather than 0 when it cannot set something up but this
# is still considered success. This happens at the moment because there is no EK certificate in
# QEMU guests.
run_tpm2_setup() {
local rc=0
"$SD_TPM2SETUP" --early=yes || rc=$?
[[ "$rc" -eq 0 || "$rc" -eq 69 ]]
}
# Temporarily override sd-pcrextend's sanity checks
export SYSTEMD_FORCE_MEASURE=1
# Temporarily override the PCR signing key and PCR signatures to create a signed
# PCR policy that's valid for the current state
mv /run/systemd/tpm2-pcr-public-key.pem /run/systemd/tpm2-pcr-public-key.pem.bak
# XXX: This gets removed in TEST-70-TPM2.measure.sh, but the intention here is to preserve the original one.
touch /run/systemd/tpm2-pcr-signature.json
mv /run/systemd/tpm2-pcr-signature.json /run/systemd/tpm2-pcr-signature.json.bak
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "/tmp/tpm2-pcr-private-key.pem"
openssl rsa -pubout -in "/tmp/tpm2-pcr-private-key.pem" -out "/run/systemd/tpm2-pcr-public-key.pem"
"$SD_MEASURE" sign --current --bank sha256 --private-key="/tmp/tpm2-pcr-private-key.pem" --public-key="/run/systemd/tpm2-pcr-public-key.pem" --phase=":" --policyref="initrd" >"/run/systemd/tpm2-pcr-signature.json"
mkdir -p /run/nvpcr
cat >/run/nvpcr/test.nvpcr <<EOF
{"name":"test","algorithm":"sha256","nvIndex":30474762}
EOF
run_tpm2_setup
test -f /run/systemd/nvpcr/test.auth
# The initial measurement is all zeroes
DIGEST_BASE_UNINITIALIZED="0000000000000000000000000000000000000000000000000000000000000000"
DIGEST_INITIAL_MEASUREMENT="0000000000000000000000000000000000000000000000000000000000000000"
DIGEST_BASE_EXPECTED=$(echo "$DIGEST_BASE_UNINITIALIZED$DIGEST_INITIAL_MEASUREMENT" | tr '[:lower:]' '[:upper:]' | basenc --base16 -d | openssl dgst -sha256 -hex -r | cut -d' ' -f1)
DIGEST_BASE="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_BASE" = "$DIGEST_BASE_EXPECTED"
ANCHOR_MEASUREMENT_STRING_EXPECTED="nvpcr-init:test:0x1d1020a:$(tpm2_nvreadpublic 0x01d1020a | awk '/name:/{print $2; exit}')"
ANCHOR_MEASUREMENT_STRING=$(jq --seq --slurp -r '[.[] | select(.content.eventType=="nvpcr-init") | .content.string] | last' </run/log/systemd/tpm2-measure.log)
test "$ANCHOR_MEASUREMENT_STRING" = "$ANCHOR_MEASUREMENT_STRING_EXPECTED"
"$SD_PCREXTEND" --nvpcr=test schrumpel
DIGEST_MEASURED="$(echo -n "schrumpel" | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
DIGEST_EXPECTED="$(echo "$DIGEST_BASE$DIGEST_MEASURED" | tr '[:lower:]' '[:upper:]' | basenc --base16 -d | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
DIGEST_ACTUAL="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_ACTUAL" = "$DIGEST_EXPECTED"
# Now "destroy" the value via another measurement (this time we use Varlink, to test the API)
varlinkctl call /usr/lib/systemd/systemd-pcrextend io.systemd.PCRExtend.Extend '{"nvpcr":"test","text":"schnurz"}'
DIGEST_ACTUAL2="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_ACTUAL2" != "$DIGEST_EXPECTED"
# And calculate the new result
DIGEST_MEASURED2="$(echo -n "schnurz" | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
DIGEST_EXPECTED2="$(echo "$DIGEST_EXPECTED$DIGEST_MEASURED2" | tr '[:lower:]' '[:upper:]' | basenc --base16 -d | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
test "$DIGEST_ACTUAL2" = "$DIGEST_EXPECTED2"
# Make sure that systemd-tpm2-setup recognizes the pre-existing NV index as valid, to simulate
# what we expect on a fresh boot.
POLICY=$(tpm2_nvreadpublic 0x01d1020a | awk '/authorization policy:/{print $3; exit}')
NAME_EXPECTED=$(tpm2_nvreadpublic 0x01d1020a | awk '/name:/{print $2; exit}')
echo "$POLICY" | basenc --base16 -d >/tmp/test.policy
rm -f /run/systemd/nvpcr/test.auth
tpm2_nvundefine -C o 0x01d1020a
tpm2_nvdefine -C o -s 32 -g sha256 -a "policywrite|ownerread|authread|orderly|clear_stclear|nt=extend" -L /tmp/test.policy 0x01d1020a
run_tpm2_setup
test -f /run/systemd/nvpcr/test.auth
DIGEST_ACTUAL3="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_ACTUAL3" = "$DIGEST_BASE_EXPECTED"
NAME_ACTUAL=$(tpm2_nvreadpublic 0x01d1020a | awk '/name:/{print $2; exit}')
test "$NAME_ACTUAL" = "$NAME_EXPECTED"
# Make sure that systemd-tpm2-setup is able to replace a pre-existing NvPCR index associated
# with a different PCR signing key.
OLD_POLICY="0000000000000000000000000000000000000000000000000000000000000000"
echo "$OLD_POLICY" | basenc --base16 -d >/tmp/test.policy
rm -f /run/systemd/nvpcr/test.auth
tpm2_nvundefine -C o 0x01d1020a
tpm2_nvdefine -C o -s 32 -g sha256 -a "policywrite|ownerread|authread|orderly|clear_stclear|nt=extend" -L /tmp/test.policy 0x01d1020a
run_tpm2_setup
test -f /run/systemd/nvpcr/test.auth
DIGEST_ACTUAL4="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_ACTUAL4" = "$DIGEST_BASE_EXPECTED"
NAME_ACTUAL=$(tpm2_nvreadpublic 0x01d1020a | awk '/name:/{print $2; exit}')
test "$NAME_ACTUAL" = "$NAME_EXPECTED"
# Make sure that systemd-tpm2-setup is able to upgrade old-style NvPCRs automatically.
rm -f /run/systemd/nvpcr/test.auth
tpm2_nvundefine -C o 0x01d1020a
tpm2_nvdefine -C o -s 32 -g sha256 -a "ownerwrite|authwrite|ownerread|authread|orderly|clear_stclear|nt=extend" 0x01d1020a
run_tpm2_setup
test -f /run/systemd/nvpcr/test.auth
DIGEST_ACTUAL5="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .value')"
test "$DIGEST_ACTUAL5" = "$DIGEST_BASE_EXPECTED"
NAME_ACTUAL=$(tpm2_nvreadpublic 0x01d1020a | awk '/name:/{print $2; exit}')
test "$NAME_ACTUAL" = "$NAME_EXPECTED"
# Verify the 'priority' field round-trips through the JSON definition. The 'test' NvPCR above sets no
# priority, so it must report the default (1000).
PRIORITY_DEFAULT="$(systemd-analyze nvpcrs test --json=pretty | jq -r '.[] | select(.name=="test") | .priority')"
test "$PRIORITY_DEFAULT" = "1000"
# A definition with an explicit priority must report exactly that value.
cat >/run/nvpcr/test2.nvpcr <<EOF
{"name":"test2","algorithm":"sha256","nvIndex":30474763,"priority":42}
EOF
PRIORITY_EXPLICIT="$(systemd-analyze nvpcrs test2 --json=pretty | jq -r '.[] | select(.name=="test2") | .priority')"
test "$PRIORITY_EXPLICIT" = "42"
# Verify NvPCRs are allocated in order of priority (lower value = more important = allocated first),
# independent of lexical filename order. 'aaa' is lexically first but less important (higher priority
# value), while 'zzz' is lexically last but more important (lower priority value), so 'zzz' must be set
# up before 'aaa'.
cat >/run/nvpcr/aaa.nvpcr <<EOF
{"name":"aaa","algorithm":"sha256","nvIndex":30474772,"priority":900}
EOF
cat >/run/nvpcr/zzz.nvpcr <<EOF
{"name":"zzz","algorithm":"sha256","nvIndex":30474773,"priority":100}
EOF
SETUP_LOG="$(run_tpm2_setup 2>&1)"
AAA_LINE="$(echo "$SETUP_LOG" | grep -n "Setting up NvPCR 'aaa'" | cut -d: -f1)"
ZZZ_LINE="$(echo "$SETUP_LOG" | grep -n "Setting up NvPCR 'zzz'" | cut -d: -f1)"
test "$ZZZ_LINE" -lt "$AAA_LINE"
# Verify that we can't redefine an NvPCR once we've exitted early boot by extending PCR11.
rm -f /run/systemd/nvpcr/test.auth
tpm2_nvundefine -C o 0x01d1020a
"$SD_PCREXTEND" --pcr 11 "foo"
SETUP_LOG="$(rc=0; $SD_TPM2SETUP --early=yes 2>&1 || rc=$?; [[ "$rc" -ne 0 ]] && [[ "$rc" -ne 69 ]])"
grep -F "Failed to initialize NvPCR index: Device not a stream" <<<"$SETUP_LOG" >/dev/null
# Test the --login= mode and the 'login' NvPCR, used in production by systemd-pcrlogin@.service.
if [[ -f /usr/lib/nvpcr/login.nvpcr ]]; then
login_nvpcr_value() {
systemd-analyze nvpcrs login --json=pretty | jq -r '.[] | select(.name=="login") | .value'
}
# Extract the most recently measured word for the 'login' NvPCR from the event log.
login_last_word() {
jq --seq --slurp -r '[.[] | select(.content.nvIndexName=="login") | .content.string] | last' </run/log/systemd/tpm2-measure.log
}
# Measure root's user record. This lazily initializes the 'login' NvPCR if it isn't already.
"$SD_PCREXTEND" --login=root
# The 'login' NvPCR must now exist and carry a non-empty value.
LOGIN_DIGEST1="$(login_nvpcr_value)"
test -n "$LOGIN_DIGEST1"
test "$LOGIN_DIGEST1" != "null"
# A matching event log entry must be present (the word is "login:<name>:<canonical json>").
grep -F '"nvIndexName":"login","string":"login:root:' /run/log/systemd/tpm2-measure.log >/dev/null
LOGIN_WORD_BY_NAME="$(login_last_word)"
# Looking the same user up by numeric UID must yield the identical measured word
# (systemd-pcrextend uses USERDB_PARSE_NUMERIC, and systemd-pcrlogin@.service is instanced by UID).
"$SD_PCREXTEND" --login=0
LOGIN_WORD_BY_UID="$(login_last_word)"
test "$LOGIN_WORD_BY_NAME" = "$LOGIN_WORD_BY_UID"
# Direct tool invocations always re-extend (the once-per-boot guarantee lives in the unit's
# RemainAfterExit=yes, not in the tool), so the NvPCR value must have advanced.
LOGIN_DIGEST2="$(login_nvpcr_value)"
test "$LOGIN_DIGEST2" != "$LOGIN_DIGEST1"
fi
systemd-analyze identify-tpm2
udevadm test-builtin 'tpm2_id identify' /dev/tpmrm0
# systemd-dissect calls io.systemd.PCRExtend over Varlink to extend the verity NvPCR after activation,
# but systemd-pcrextend.socket has ConditionSecurity=measured-os which fails when the firmware did not
# initialize PCRs (e.g. when not booting via a signed UKI). Skip the rest in that case, otherwise the
# 'diff | grep' below would find no new measurement and fail.
if ! systemctl is-active --quiet systemd-pcrextend.socket; then
echo "systemd-pcrextend.socket not active, skipping verity NvPCR measurement check"
exit 0
fi
mkdir -p /tmp/nvpcr/tree
touch /tmp/nvpcr/tree/file
if machine_supports_verity_keyring; then
SYSTEMD_REPART_OVERRIDE_FSTYPE=squashfs \
systemd-repart -P \
-s /tmp/nvpcr/tree \
--certificate=/usr/share/mkosi.crt \
--private-key=/usr/share/mkosi.key \
/var/tmp/nvpcr.raw
else
OPENSSL_CONFIG="/tmp/nvpcr/opensslconfig"
# Unfortunately OpenSSL insists on reading some config file, hence provide one with mostly placeholder contents
cat >"${OPENSSL_CONFIG:?}" <<EOF
[ req ]
prompt = no
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
C = DE
ST = Test State
L = Test Locality
O = Org Name
OU = Org Unit Name
CN = Common Name
emailAddress = test@email.com
EOF
openssl req -config "$OPENSSL_CONFIG" -subj="/CN=waldo" \
-x509 -sha256 -nodes -days 365 -newkey rsa:4096 \
-keyout /tmp/nvpcr/test-70-nvpcr.key -out /tmp/nvpcr/test-70-nvpcr.crt
SYSTEMD_REPART_OVERRIDE_FSTYPE=squashfs \
systemd-repart -P \
-s /tmp/nvpcr/tree \
--certificate=/tmp/nvpcr/test-70-nvpcr.crt \
--private-key=/tmp/nvpcr/test-70-nvpcr.key \
/var/tmp/nvpcr.raw
mkdir -p /run/verity.d
cp /tmp/nvpcr/test-70-nvpcr.crt /run/verity.d/
fi
cp /run/log/systemd/tpm2-measure.log /tmp/nvpcr/log-before
systemd-dissect --image-policy='root=signed:=absent+unused' --mtree /var/tmp/nvpcr.raw
set +o pipefail
diff /tmp/nvpcr/log-before /run/log/systemd/tpm2-measure.log | grep -F '"content":{"nvIndexName":"verity","string":"verity:'