diff --git a/TODO.md b/TODO.md
index ed7c40c2dbb..8b8451c9212 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1989,7 +1989,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
- pre-calc cryptsetup root key measurement
- maybe make systemd-repart generate .pcrlock for old and new GPT header in
/run?
- - Add support for more than 8 branches per PCR OR
- add "systemd-pcrlock lock-kernel-current" or so which synthesizes .pcrlock
policy from currently booted kernel/event log, to close gap for first boot
for pre-built images
diff --git a/man/systemd-pcrlock.xml b/man/systemd-pcrlock.xml
index 24f9978eb6b..f5c116cd4d7 100644
--- a/man/systemd-pcrlock.xml
+++ b/man/systemd-pcrlock.xml
@@ -74,6 +74,10 @@
then stored in an NV index in the TPM2. This may be used to then lock secrets (such as disk encryption
keys) to these policies (via a TPM2 PolicyAuthorizeNV policy).
+ If a single PCR has more than eight alternative values, the PolicyOR items are
+ nested into a balanced tree. The current implementation supports at most 5 nesting levels, i.e. up to
+ 32768 alternatives per PCR.
+
Use tools such as
systemd-cryptenroll1
or systemd-repart8 to
diff --git a/man/systemd.pcrlock.xml b/man/systemd.pcrlock.xml
index d27d13b9617..8d06ef20851 100644
--- a/man/systemd.pcrlock.xml
+++ b/man/systemd.pcrlock.xml
@@ -68,6 +68,11 @@
component. Example: 650-kernel.pcrlock.d/6.5.5-200.fc38.x86_64.pcrlock and
650-kernel.pcrlock.d/6.5.7-100.fc38.x86_64.pcrlock
+ Components may have any number of variants. If a PCR ends up with more than eight alternative
+ values, systemd-pcrlock expresses them in the TPM2 access policy as a balanced tree of
+ nested PolicyOR statements. Up to 8^5 = 32768 alternative values per PCR are
+ supported.
+
Use systemd-pcrlock list-components to list all pcrlock files currently
installed.
diff --git a/src/shared/tpm2-util.c b/src/shared/tpm2-util.c
index 10e3b843664..b8349463ec3 100644
--- a/src/shared/tpm2-util.c
+++ b/src/shared/tpm2-util.c
@@ -5498,55 +5498,80 @@ int tpm2_policy_authorize_nv(
return tpm2_get_policy_digest(c, session, ret_policy_digest);
}
-int tpm2_policy_or(
- Tpm2Context *c,
- const Tpm2Handle *session,
- const TPM2B_DIGEST *branches, size_t n_branches,
- TPM2B_DIGEST **ret_policy_digest) {
+/* TPM2_PolicyOR() takes at most TPM2_POLICY_OR_MAX_BRANCHES digests. To support more alternatives we nest
+ * ORs into a balanced tree with that fanout. All leaves are at the same depth and the upper levels contain
+ * only ORs, as the TCG TSS JSON policy format requires. The exact partition below is part of the on-disk
+ * policy format: sealing (tpm2_calculate_policy_or()) and unsealing (tpm2_policy_or()) must derive the same
+ * tree forever, so group sizes and ordering must not change. */
- TPML_DIGEST hash_list;
- TSS2_RC rc;
-
- assert(c);
- assert(session);
-
- if (n_branches > ELEMENTSOF(hash_list.digests))
- return -EOPNOTSUPP;
-
- log_debug("Submitting OR policy.");
-
- hash_list = (TPML_DIGEST) {
- .count = n_branches,
- };
-
- memcpy(hash_list.digests, branches, n_branches * sizeof(TPM2B_DIGEST));
-
- if (DEBUG_LOGGING)
- for (size_t i = 0; i < hash_list.count; i++) {
- _cleanup_free_ char *h = hexmem(hash_list.digests[i].buffer, hash_list.digests[i].size);
- log_debug("Submitting OR Branch #%zu: %s", i, h);
- }
-
- rc = sym_Esys_PolicyOR(
- c->esys_context,
- session->esys_handle,
- ESYS_TR_NONE,
- ESYS_TR_NONE,
- ESYS_TR_NONE,
- &hash_list);
- if ((rc & ~(TPM2_RC_N_MASK|TPM2_RC_P)) == TPM2_RC_VALUE) /* Return a recognizable error if none of the OR branches matched */
- return log_debug_errno(SYNTHETIC_ERRNO(ENOANO),
- "None of the PolicyOR branches matched the current policy state.");
- if (rc != TSS2_RC_SUCCESS)
- return log_debug_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
- "Failed to add OR policy to TPM: %s",
- sym_Tss2_RC_Decode(rc));
-
- return tpm2_get_policy_digest(c, session, ret_policy_digest);
+/* Number of ORs needed to combine 'n' nodes into one level up. */
+static size_t tpm2_policy_or_tree_n_groups(size_t n) {
+ return DIV_ROUND_UP(n, TPM2_POLICY_OR_MAX_BRANCHES);
}
-/* Extend 'digest' with the PolicyOR calculated hash. */
-int tpm2_calculate_policy_or(const TPM2B_DIGEST *branches, size_t n_branches, TPM2B_DIGEST *digest) {
+/* Number of nested OR levels needed to collapse 'n' branches into one digest. A flat OR (n <= 8) has
+ * depth 1, i.e. one level, and the tree has depth+1 levels in total including the leaves. Because the fanout
+ * is TPM2_POLICY_OR_MAX_BRANCHES, depth 'd' supports up to TPM2_POLICY_OR_MAX_BRANCHES^d alternatives, i.e.
+ * TPM2_POLICY_OR_MAX_DEPTH = 5 levels allow up to 8^5 = 32768 alternatives per PCR. */
+static size_t tpm2_policy_or_tree_depth(size_t n) {
+ size_t depth = 0;
+
+ while (n > 1) {
+ n = tpm2_policy_or_tree_n_groups(n);
+ depth++;
+ }
+
+ return depth;
+}
+
+/* Whether a PolicyOR expression over 'n' branches stays within TPM2_POLICY_OR_MAX_DEPTH levels. Callers use
+ * this to reject overly large branch lists before they build them, so that the offending PCR can be named
+ * and no work is wasted on the TPM round trips preceding it. */
+static bool tpm2_policy_or_branches_supported(size_t n) {
+ return n > 0 && tpm2_policy_or_tree_depth(n) <= TPM2_POLICY_OR_MAX_DEPTH;
+}
+
+/* Distribute 'n' nodes evenly over tpm2_policy_or_tree_n_groups(n) groups, with the first n%g groups getting
+ * one extra node. This avoids single-branch ORs. Return the slice [*ret_start, *ret_start + *ret_size) held
+ * by group 'group'. */
+static void tpm2_policy_or_tree_group_slice(size_t n, size_t group, size_t *ret_start, size_t *ret_size) {
+ size_t g, base, rem;
+
+ assert(n > 0);
+ assert(ret_start);
+ assert(ret_size);
+
+ g = tpm2_policy_or_tree_n_groups(n);
+ base = n / g;
+ rem = n % g;
+
+ *ret_size = base + (group < rem);
+ *ret_start = group < rem ? group * (base + 1) :
+ rem * (base + 1) + (group - rem) * base;
+}
+
+/* Inverse of tpm2_policy_or_tree_group_slice(): return which group of a level with 'n' nodes holds 'index'. */
+static size_t tpm2_policy_or_tree_group_of(size_t n, size_t index) {
+ size_t g, base, rem, first_big;
+
+ assert(n > 0);
+ assert(index < n);
+
+ g = tpm2_policy_or_tree_n_groups(n);
+ base = n / g;
+ rem = n % g;
+ first_big = rem * (base + 1);
+
+ return index < first_big ? index / (base + 1) :
+ rem + (index - first_big) / base;
+}
+
+/* Extend 'digest' with the PolicyOR calculated hash, for a single flat OR with 1…8 branches. */
+static int tpm2_calculate_policy_or_flat(
+ const TPM2B_DIGEST *branches,
+ size_t n_branches,
+ TPM2B_DIGEST *digest) {
+
TPM2_CC command = TPM2_CC_PolicyOR;
TSS2_RC rc;
int r;
@@ -5556,10 +5581,10 @@ int tpm2_calculate_policy_or(const TPM2B_DIGEST *branches, size_t n_branches, TP
if (n_branches == 0)
return -EINVAL;
+ if (n_branches > TPM2_POLICY_OR_MAX_BRANCHES)
+ return -E2BIG;
if (n_branches == 1)
log_warning("PolicyOR with a single branch submitted, this is weird.");
- if (n_branches > 8)
- return -E2BIG;
r = dlopen_tpm2(LOG_ERR);
if (r < 0)
@@ -5602,6 +5627,216 @@ int tpm2_calculate_policy_or(const TPM2B_DIGEST *branches, size_t n_branches, TP
return 0;
}
+/* A fully materialized balanced PolicyOR tree: levels[0] are the n_level[0] leaves, and the last level holds
+ * exactly one digest (the root). Intermediate levels hold the flat OR of each group of the level below. */
+typedef struct Tpm2PolicyORTree {
+ TPM2B_DIGEST **levels;
+ size_t *n_level;
+ size_t n_levels;
+} Tpm2PolicyORTree;
+
+static void tpm2_policy_or_tree_done(Tpm2PolicyORTree *t) {
+ if (!t)
+ return;
+
+ if (t->levels)
+ for (size_t i = 0; i < t->n_levels; i++)
+ free(t->levels[i]);
+
+ free(t->levels);
+ free(t->n_level);
+ *t = (Tpm2PolicyORTree) {};
+}
+
+static int tpm2_policy_or_tree_build(
+ const TPM2B_DIGEST *branches,
+ size_t n_branches,
+ Tpm2PolicyORTree *ret) {
+
+ size_t depth;
+ int r;
+
+ assert(branches);
+ assert(n_branches > TPM2_POLICY_OR_MAX_BRANCHES);
+ assert(ret);
+
+ depth = tpm2_policy_or_tree_depth(n_branches);
+ if (depth > TPM2_POLICY_OR_MAX_DEPTH)
+ return log_error_errno(SYNTHETIC_ERRNO(E2BIG),
+ "PolicyOR tree for %zu branches exceeds %u levels.",
+ n_branches, TPM2_POLICY_OR_MAX_DEPTH);
+
+ _cleanup_(tpm2_policy_or_tree_done) Tpm2PolicyORTree tree = {
+ .n_levels = depth + 1,
+ };
+
+ tree.levels = new0(TPM2B_DIGEST*, tree.n_levels);
+ tree.n_level = new0(size_t, tree.n_levels);
+ if (!tree.levels || !tree.n_level)
+ return log_oom_debug();
+
+ tree.n_level[0] = n_branches;
+ tree.levels[0] = newdup(TPM2B_DIGEST, branches, n_branches);
+ if (!tree.levels[0])
+ return log_oom_debug();
+
+ for (size_t i = 0; i + 1 < tree.n_levels; i++) {
+ size_t n_groups = tpm2_policy_or_tree_n_groups(tree.n_level[i]);
+
+ tree.n_level[i + 1] = n_groups;
+ tree.levels[i + 1] = new0(TPM2B_DIGEST, n_groups);
+ if (!tree.levels[i + 1])
+ return log_oom_debug();
+
+ for (size_t g = 0; g < n_groups; g++) {
+ size_t start, size;
+
+ tree.levels[i + 1][g].size = SHA256_DIGEST_SIZE;
+
+ tpm2_policy_or_tree_group_slice(tree.n_level[i], g, &start, &size);
+ r = tpm2_calculate_policy_or_flat(
+ tree.levels[i] + start,
+ size,
+ &tree.levels[i + 1][g]);
+ if (r < 0)
+ return r;
+ }
+ }
+
+ *ret = TAKE_STRUCT(tree);
+ return 0;
+}
+
+/* Submit a single flat PolicyOR (1…8 branches) to the TPM. */
+static int tpm2_policy_or_submit_flat(
+ Tpm2Context *c,
+ const Tpm2Handle *session,
+ const TPM2B_DIGEST *branches,
+ size_t n_branches) {
+
+ TPML_DIGEST hash_list;
+ TSS2_RC rc;
+
+ assert_cc(ELEMENTSOF(((TPML_DIGEST) {}).digests) == TPM2_POLICY_OR_MAX_BRANCHES);
+
+ assert(c);
+ assert(session);
+ assert(n_branches <= TPM2_POLICY_OR_MAX_BRANCHES);
+
+ log_debug("Submitting OR policy.");
+
+ hash_list = (TPML_DIGEST) {
+ .count = n_branches,
+ };
+
+ memcpy(hash_list.digests, branches, n_branches * sizeof(TPM2B_DIGEST));
+
+ if (DEBUG_LOGGING)
+ for (size_t i = 0; i < hash_list.count; i++) {
+ _cleanup_free_ char *h = hexmem(hash_list.digests[i].buffer, hash_list.digests[i].size);
+ log_debug("Submitting OR Branch #%zu: %s", i, h);
+ }
+
+ rc = sym_Esys_PolicyOR(
+ c->esys_context,
+ session->esys_handle,
+ ESYS_TR_NONE,
+ ESYS_TR_NONE,
+ ESYS_TR_NONE,
+ &hash_list);
+ if (TPM2_RC_IS_POLICY_OR_NO_MATCH(rc)) /* Return a recognizable error if none of the OR branches matched */
+ return log_debug_errno(SYNTHETIC_ERRNO(ENOANO),
+ "None of the PolicyOR branches matched the current policy state.");
+ if (rc != TSS2_RC_SUCCESS)
+ return log_debug_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
+ "Failed to add OR policy to TPM: %s",
+ sym_Tss2_RC_Decode(rc));
+
+ return 0;
+}
+
+int tpm2_policy_or(
+ Tpm2Context *c,
+ const Tpm2Handle *session,
+ const TPM2B_DIGEST *branches, size_t n_branches,
+ TPM2B_DIGEST **ret_policy_digest) {
+
+ int r;
+
+ assert(c);
+ assert(session);
+
+ if (n_branches <= TPM2_POLICY_OR_MAX_BRANCHES) {
+ r = tpm2_policy_or_submit_flat(c, session, branches, n_branches);
+ if (r < 0)
+ return r;
+
+ return tpm2_get_policy_digest(c, session, ret_policy_digest);
+ }
+
+ _cleanup_(tpm2_policy_or_tree_done) Tpm2PolicyORTree tree = {};
+ r = tpm2_policy_or_tree_build(branches, n_branches, &tree);
+ if (r < 0)
+ return r;
+
+ /* The immediately preceding policy command (typically PolicyPCR) left the session digest equal to
+ * one of our leaves. Find it, then walk from it up to the root, one OR per level. */
+ _cleanup_(Esys_Freep) TPM2B_DIGEST *current = NULL;
+ r = tpm2_get_policy_digest(c, session, ¤t);
+ if (r < 0)
+ return r;
+
+ size_t index = SIZE_MAX;
+ for (size_t i = 0; i < tree.n_level[0]; i++)
+ if (memcmp_nn(tree.levels[0][i].buffer, tree.levels[0][i].size,
+ current->buffer, current->size) == 0) {
+ index = i;
+ break;
+ }
+
+ if (index == SIZE_MAX)
+ return log_debug_errno(SYNTHETIC_ERRNO(ENOANO),
+ "None of the PolicyOR branches matched the current policy state.");
+
+ for (size_t i = 0; i + 1 < tree.n_levels; i++) {
+ size_t group, start, size;
+
+ /* PolicyOR can only fail with TPM2_RC_VALUE if the session digest is not one of the submitted
+ * digests. Every slice we submit does contain it: level 0's slice contains the leaf we just
+ * matched, and after each OR the session digest is exactly levels[i + 1][group], which the
+ * slice for the next level contains by construction. */
+ group = tpm2_policy_or_tree_group_of(tree.n_level[i], index);
+ tpm2_policy_or_tree_group_slice(tree.n_level[i], group, &start, &size);
+
+ r = tpm2_policy_or_submit_flat(c, session, tree.levels[i] + start, size);
+ if (r < 0)
+ return r;
+
+ index = group;
+ }
+
+ return tpm2_get_policy_digest(c, session, ret_policy_digest);
+}
+
+/* Extend 'digest' with the PolicyOR calculated hash. */
+int tpm2_calculate_policy_or(const TPM2B_DIGEST *branches, size_t n_branches, TPM2B_DIGEST *digest) {
+ assert(digest);
+ assert(digest->size == SHA256_DIGEST_SIZE);
+
+ if (n_branches <= TPM2_POLICY_OR_MAX_BRANCHES)
+ return tpm2_calculate_policy_or_flat(branches, n_branches, digest);
+
+ _cleanup_(tpm2_policy_or_tree_done) Tpm2PolicyORTree tree = {};
+ int r;
+
+ r = tpm2_policy_or_tree_build(branches, n_branches, &tree);
+ if (r < 0)
+ return r;
+
+ *digest = tree.levels[tree.n_levels - 1][0];
+ return 0;
+}
+
/* Extend 'digest' with the PolicyPCR calculated hash. */
int tpm2_calculate_policy_pcr(
const Tpm2PCRValue *pcr_values,
@@ -10170,17 +10405,20 @@ int tpm2_calculate_policy_super_pcr(
/* Now deal with the PCRs for which we have variants, i.e. more than one allowed values */
for (uint32_t pcr = 0; pcr < TPM2_PCRS_MAX; pcr++) {
_cleanup_free_ TPM2B_DIGEST *pcr_policy_digest_variants = NULL;
- size_t n_pcr_policy_digest_variants = 0;
+ size_t n_pcr_policy_digest_variants = 0, n_variants;
Tpm2PCRPredictionResult *banks;
if (!BIT_SET(prediction->pcrs, pcr))
continue;
- if (ordered_set_size(prediction->results[pcr]) <= 1) /* We only care for PCRs with 2 or more variants in this loop */
+ n_variants = ordered_set_size(prediction->results[pcr]);
+ if (n_variants <= 1) /* We only care for PCRs with 2 or more variants in this loop */
continue;
- if (ordered_set_size(prediction->results[pcr]) > 8)
- return log_error_errno(SYNTHETIC_ERRNO(E2BIG), "PCR policies with more than 8 alternatives per PCR are currently not supported.");
+ if (!tpm2_policy_or_branches_supported(n_variants))
+ return log_error_errno(SYNTHETIC_ERRNO(E2BIG),
+ "PCR %" PRIu32 " has %zu predicted values, which needs more than the %u nested PolicyOR levels we support.",
+ pcr, n_variants, TPM2_POLICY_OR_MAX_DEPTH);
ORDERED_SET_FOREACH(banks, prediction->results[pcr]) {
/* Start from the super PCR policy from the previous PCR we looked at so far. */
@@ -10211,7 +10449,6 @@ int tpm2_calculate_policy_super_pcr(
}
assert_se(n_pcr_policy_digest_variants >= 2);
- assert_se(n_pcr_policy_digest_variants <= 8);
/* Now combine all our variant into one OR policy */
r = tpm2_calculate_policy_or(
@@ -10284,8 +10521,13 @@ int tpm2_policy_super_pcr(
continue;
n_branches = ordered_set_size(prediction->results[pcr]);
- if (n_branches < 1 || n_branches > 8)
- return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Number of variants per PCR not in range 1…8");
+ if (n_branches < 1)
+ return log_error_errno(SYNTHETIC_ERRNO(EBADMSG),
+ "PCR %" PRIu32 " has no predicted values.", pcr);
+ if (!tpm2_policy_or_branches_supported(n_branches))
+ return log_error_errno(SYNTHETIC_ERRNO(E2BIG),
+ "PCR %" PRIu32 " has %zu predicted values, which needs more than the %u nested PolicyOR levels we support.",
+ pcr, n_branches, TPM2_POLICY_OR_MAX_DEPTH);
if (n_branches == 1) /* Single choice PCRs are already covered by the loop above */
continue;
diff --git a/src/shared/tpm2-util.h b/src/shared/tpm2-util.h
index 609d2aa93c7..c1d488c2579 100644
--- a/src/shared/tpm2-util.h
+++ b/src/shared/tpm2-util.h
@@ -348,6 +348,33 @@ int tpm2_policy_authorize_nv(Tpm2Context *c, const Tpm2Handle *session, const Tp
* moved while the policy was submitted, i.e. if something extended a PCR in the meantime. That's transient,
* callers are supposed to restart the policy session and try again. */
int tpm2_policy_pcr(Tpm2Context *c, const Tpm2Handle *session, const TPML_PCR_SELECTION *pcr_selection, TPM2B_DIGEST **ret_policy_digest);
+
+/* TPM2_PolicyOR() accepts at most TPM2_POLICY_OR_MAX_BRANCHES branches (the TPM's TPML_DIGEST is fixed at 8
+ * elements). To support more alternatives the PolicyOR helpers nest ORs into a balanced tree with that
+ * fanout. TPM2_POLICY_OR_MAX_DEPTH caps the nesting depth, and hence the number of alternatives per PCR at
+ * TPM2_POLICY_OR_MAX_BRANCHES^TPM2_POLICY_OR_MAX_DEPTH = 8^5 = 32768. The cap ensures unsealing never needs
+ * an unbounded number of TPM round trips. */
+#define TPM2_POLICY_OR_MAX_BRANCHES 8U
+#define TPM2_POLICY_OR_MAX_DEPTH 5U
+
+/* TPM2_PolicyOR() has a single parameter, pHashList. When the TPM rejects the command it does so with
+ * TPM_RC_VALUE on that parameter: a format-one code with TPM_RC_P set and the 1-based parameter number in N.
+ * Classify those fields explicitly instead of masking N away the way we do for other commands: TPM_RC_S
+ * shares bit 11 with the top bit of N, so masking N away hides the session bit too and would turn a
+ * session-related (or otherwise unrelated) TPM_RC_VALUE into a bogus "no branch matched". */
+static inline bool TPM2_RC_IS_POLICY_OR_NO_MATCH(TSS2_RC rc) {
+ if (!(rc & TPM2_RC_P)) /* A handle- or session-related error, or not parameter-related at all */
+ return false;
+ if (rc & TPM2_RC_S) /* Session-related, hence not about pHashList */
+ return false;
+ if ((rc & ~(TPM2_RC_N_MASK | TPM2_RC_P)) != TPM2_RC_VALUE)
+ return false;
+
+ /* N is the parameter number, i.e. 1 for PolicyOR's only parameter. TPMs that leave it at 0 are
+ * tolerated. */
+ return ((rc & TPM2_RC_N_MASK) >> 8) <= 1;
+}
+
int tpm2_policy_or(Tpm2Context *c, const Tpm2Handle *session, const TPM2B_DIGEST *branches, size_t n_branches, TPM2B_DIGEST **ret_policy_digest);
int tpm2_policy_super_pcr(Tpm2Context *c, const Tpm2Handle *session, const Tpm2PCRPrediction *prediction, uint16_t algorithm);
int tpm2_policy_signed_hmac_sha256(Tpm2Context *c, const Tpm2Handle *session, const Tpm2Handle *hmac_key_handle, const struct iovec *hmac_key, TPM2B_DIGEST **ret_policy_digest);
diff --git a/src/test/test-tpm2.c b/src/test/test-tpm2.c
index d7964ec5af1..d6c8fa0ee6d 100644
--- a/src/test/test-tpm2.c
+++ b/src/test/test-tpm2.c
@@ -6,6 +6,7 @@
#include "hexdecoct.h"
#include "iovec-util.h"
#include "memory-util.h"
+#include "ordered-set.h"
#include "random-util.h"
#include "tests.h"
#include "tpm2-util.h"
@@ -1257,6 +1258,191 @@ TEST(calculate_policy_pcr) {
assert_se(digest_check(&d, "7481fd1b116078eb3ac2456e4ad542c9b46b9b8eb891335771ca8e7c8f8e4415"));
}
+/* Fill a digest with a value that is distinct for every index, so that grouping two different indices
+ * together can never accidentally compare equal. */
+static void fill_indexed_digest(TPM2B_DIGEST *digest, size_t index) {
+ uint64_t state = index;
+
+ *digest = (TPM2B_DIGEST) { .size = SHA256_DIGEST_SIZE };
+
+ for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) {
+ /* The first sizeof(uint64_t) bytes encode the index verbatim (independent of the host's
+ * size_t width, so the digests are identical on every architecture, such as armhf). The
+ * remaining bytes are a cheap expansion. */
+ digest->buffer[i] = i < sizeof(uint64_t) ? (uint8_t) ((uint64_t) index >> (8 * i)) : (uint8_t) state;
+
+ /* This is a full-period LCG mod 2^64, the constants are taken from the PCG reference
+ * implementation. */
+ state = state * UINT64_C(6364136223846793005) + UINT64_C(1442695040888963407);
+ }
+}
+
+static size_t sha256_index(void) {
+ for (size_t i = 0; i < TPM2_N_HASH_ALGORITHMS; i++)
+ if (tpm2_hash_algorithms[i] == TPM2_ALG_SHA256)
+ return i;
+
+ assert_not_reached();
+}
+
+/* Independently reconstruct the balanced PolicyOR tree the implementation is supposed to build, using only
+ * flat ORs (n <= 8) for each group. This pins down the group sizes and their order. */
+static int policy_or_tree_reference(const TPM2B_DIGEST *leaves, size_t n, TPM2B_DIGEST *ret) {
+ _cleanup_free_ TPM2B_DIGEST *level = NULL;
+ size_t n_level = n;
+
+ assert_se(leaves);
+ assert_se(n > 0);
+ assert_se(ret);
+
+ level = newdup(TPM2B_DIGEST, leaves, n);
+ if (!level)
+ return -ENOMEM;
+
+ /* Collapse one level at a time until a single flat OR (n <= 8) remains. */
+ while (n_level > TPM2_POLICY_OR_MAX_BRANCHES) {
+ size_t g = DIV_ROUND_UP(n_level, TPM2_POLICY_OR_MAX_BRANCHES),
+ base = n_level / g, rem = n_level % g;
+ _cleanup_free_ TPM2B_DIGEST *next = NULL;
+
+ next = new0(TPM2B_DIGEST, g);
+ if (!next)
+ return -ENOMEM;
+
+ for (size_t i = 0, off = 0; i < g; i++) {
+ size_t size = base + (i < rem);
+ int r;
+
+ assert_se(size >= 2); /* No degenerate single-branch ORs */
+ assert_se(size <= TPM2_POLICY_OR_MAX_BRANCHES);
+
+ next[i].size = SHA256_DIGEST_SIZE;
+ r = tpm2_calculate_policy_or(level + off, size, &next[i]);
+ if (r < 0)
+ return r;
+
+ off += size;
+ }
+
+ free_and_replace(level, next);
+ n_level = g;
+ }
+
+ return tpm2_calculate_policy_or(level, n_level, ret);
+}
+
+TEST(calculate_policy_or) {
+ TPM2B_DIGEST leaves[65], reference, actual;
+
+ for (size_t i = 0; i < ELEMENTSOF(leaves); i++)
+ fill_indexed_digest(leaves + i, i);
+
+ /* The empty input must be rejected before the flat/tree split. */
+ actual = (TPM2B_DIGEST) { .size = SHA256_DIGEST_SIZE };
+ ASSERT_ERROR(tpm2_calculate_policy_or(leaves, 0, &actual), EINVAL);
+
+ /* For n<=8 the flat form must be used unchanged; for n>8 the result must match the balanced tree
+ * reference implementation. */
+ const size_t ns[] = { 1, 2, 5, 8, 9, 16, 17, 64, 65 };
+ FOREACH_ELEMENT(n, ns) {
+ reference = (TPM2B_DIGEST) { .size = SHA256_DIGEST_SIZE };
+ ASSERT_OK(policy_or_tree_reference(leaves, *n, &reference));
+
+ actual = (TPM2B_DIGEST) { .size = SHA256_DIGEST_SIZE };
+ ASSERT_OK(tpm2_calculate_policy_or(leaves, *n, &actual));
+
+ ASSERT_EQ(actual.size, reference.size);
+ ASSERT_EQ(memcmp(actual.buffer, reference.buffer, actual.size), 0);
+
+ /* Pin the flat form and one nested result to the on-disk format, independently of the
+ * shared implementation. */
+ if (*n == 8)
+ ASSERT_TRUE(digest_check(&actual, "338aff94ac60da78125690c17895df113c5598f65d500dc028fca20feeaa5ed5"));
+ if (*n == 9)
+ ASSERT_TRUE(digest_check(&actual, "806fed0eaea4bd5a7cba19d21781c4d1e099e80607f76814d46ac0812c623312"));
+ }
+
+ /* Branch order is significant. */
+ TPM2B_DIGEST reversed[9];
+ for (size_t i = 0; i < ELEMENTSOF(reversed); i++)
+ reversed[i] = leaves[ELEMENTSOF(reversed) - 1 - i];
+
+ ASSERT_OK(tpm2_calculate_policy_or(leaves, ELEMENTSOF(reversed), &actual));
+ ASSERT_OK(tpm2_calculate_policy_or(reversed, ELEMENTSOF(reversed), &reference));
+ ASSERT_EQ(actual.size, reference.size);
+ ASSERT_NE(memcmp(actual.buffer, reference.buffer, actual.size), 0);
+
+ /* Depth cap: 8^TPM2_POLICY_OR_MAX_DEPTH branches are fine, one more is not. */
+ size_t max_branches = 1;
+ for (size_t i = 0; i < TPM2_POLICY_OR_MAX_DEPTH; i++)
+ max_branches *= TPM2_POLICY_OR_MAX_BRANCHES;
+
+ _cleanup_free_ TPM2B_DIGEST *many = new0(TPM2B_DIGEST, max_branches + 1);
+ ASSERT_NOT_NULL(many);
+ for (size_t i = 0; i < max_branches + 1; i++)
+ fill_indexed_digest(many + i, i);
+
+ actual = (TPM2B_DIGEST) { .size = SHA256_DIGEST_SIZE };
+ ASSERT_OK(tpm2_calculate_policy_or(many, max_branches, &actual));
+ ASSERT_ERROR(tpm2_calculate_policy_or(many, max_branches + 1, &actual), E2BIG);
+}
+
+TEST(tpm2_rc_is_policy_or_no_match) {
+ /* How the TCG reference implementation reports "no digest in pHashList matched": TPM_RC_VALUE on
+ * parameter 1. That is the one response the PolicyOR helpers may translate into ENOANO. */
+ ASSERT_TRUE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_P + TPM2_RC_1));
+ ASSERT_TRUE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_P));
+
+ /* TPM_RC_S shares bit 11 with the top bit of N, so a session-related TPM_RC_VALUE must not read as a
+ * branch mismatch - which is exactly what masking N away would do. */
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_S + TPM2_RC_1));
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_P + (UINT32_C(8) << 8)));
+
+ /* Not pHashList. */
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_P + TPM2_RC_2));
+
+ /* Handle-related rather than parameter-related, i.e. P is clear. Format-zero codes have bit 8 set
+ * (TPM_RC_VER1 == TPM_RC_1), so requiring P also keeps those out. */
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_VALUE + TPM2_RC_1));
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_COMMAND_CODE));
+
+ /* Right parameter, different error number. */
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_SIZE + TPM2_RC_P + TPM2_RC_1));
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_HASH + TPM2_RC_P + TPM2_RC_1));
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_HANDLE + TPM2_RC_P + TPM2_RC_1));
+ ASSERT_FALSE(TPM2_RC_IS_POLICY_OR_NO_MATCH(TPM2_RC_SUCCESS));
+}
+
+TEST(calculate_policy_super_pcr_too_many_variants) {
+ /* A PCR with more alternatives than the depth cap allows must be rejected per PCR, with E2BIG,
+ * rather than surfacing as a generic PolicyOR tree failure somewhere down the call chain. */
+ size_t max_branches = 1;
+ for (size_t i = 0; i < TPM2_POLICY_OR_MAX_DEPTH; i++)
+ max_branches *= TPM2_POLICY_OR_MAX_BRANCHES;
+
+ _cleanup_(tpm2_pcr_prediction_done) Tpm2PCRPrediction prediction = {
+ .pcrs = UINT32_C(1) << 16,
+ };
+
+ for (size_t i = 0; i < max_branches + 1; i++) {
+ _cleanup_free_ Tpm2PCRPredictionResult *result = new0(Tpm2PCRPredictionResult, 1);
+ ASSERT_NOT_NULL(result);
+
+ fill_indexed_digest(result->hash + sha256_index(), i);
+
+ ASSERT_OK(ordered_set_ensure_put(
+ &prediction.results[16],
+ &tpm2_pcr_prediction_result_hash_ops,
+ result));
+ TAKE_PTR(result);
+ }
+
+ ASSERT_EQ(ordered_set_size(prediction.results[16]), max_branches + 1);
+
+ TPM2B_DIGEST digest = { .size = SHA256_DIGEST_SIZE };
+ ASSERT_ERROR(tpm2_calculate_policy_super_pcr(&prediction, TPM2_ALG_SHA256, &digest), E2BIG);
+}
+
static void check_srk_rsa_template(TPMT_PUBLIC *template) {
assert_se(template->type == TPM2_ALG_RSA);
assert_se(template->nameAlg == TPM2_ALG_SHA256);
@@ -2905,6 +3091,117 @@ static void check_get_session_audit_digest(Tpm2Context *c) {
check_attest_signature(&template, sig);
}
+/* Exercise the runtime side of the balanced PolicyOR tree: a freshly started policy session has an all-zero
+ * policy digest, so make that one of the branches and add enough further ones to push tpm2_policy_or() onto
+ * the nested code path. Its result must match the offline calculation. */
+static void check_policy_or_tree_runtime(Tpm2Context *c) {
+ assert(c);
+
+ TEST_LOG_FUNC();
+
+ _cleanup_(tpm2_handle_freep) Tpm2Handle *session = NULL;
+ ASSERT_OK(tpm2_make_policy_session(
+ c,
+ /* primary= */ NULL,
+ /* encryption_session= */ NULL,
+ &session));
+
+ TPM2B_DIGEST branches[65] = {};
+ for (size_t i = 0; i < ELEMENTSOF(branches); i++)
+ fill_indexed_digest(branches + i, i + 1);
+ /* Index 60 maps to a non-zero group on the first two levels, so the nested walk's group remapping is
+ * exercised against the TPM. */
+ zero(branches[60].buffer);
+
+ _cleanup_(Esys_Freep) TPM2B_DIGEST *tpm_digest = NULL;
+ ASSERT_OK(tpm2_policy_or(c, session, branches, ELEMENTSOF(branches), &tpm_digest));
+
+ TPM2B_DIGEST calculated = { .size = SHA256_DIGEST_SIZE };
+ ASSERT_OK(tpm2_calculate_policy_or(branches, ELEMENTSOF(branches), &calculated));
+
+ ASSERT_EQ(tpm_digest->size, calculated.size);
+ ASSERT_EQ(memcmp(tpm_digest->buffer, calculated.buffer, calculated.size), 0);
+
+ /* Another fresh session again starts from an all-zero digest. If none of the branches matches it,
+ * the leaf lookup must fail with ENOANO, which pcrlock relies on to detect tampering. */
+ session = tpm2_handle_free(session);
+ ASSERT_OK(tpm2_make_policy_session(
+ c,
+ /* primary= */ NULL,
+ /* encryption_session= */ NULL,
+ &session));
+
+ TPM2B_DIGEST no_match[65];
+ for (size_t i = 0; i < ELEMENTSOF(no_match); i++)
+ fill_indexed_digest(no_match + i, i + 1);
+
+ _cleanup_(Esys_Freep) TPM2B_DIGEST *no_match_digest = NULL;
+ ASSERT_ERROR(tpm2_policy_or(c, session, no_match, ELEMENTSOF(no_match), &no_match_digest), ENOANO);
+}
+
+/* Same for the super PCR policy, which chains a PolicyPCR and a (possibly nested) PolicyOR per PCR. We
+ * submit the policy to a live session and read the resulting digest back by extending it once more with
+ * PolicyAuthValue, which is a pure function of the previous digest. */
+static void check_policy_super_pcr_tree(Tpm2Context *c) {
+ assert(c);
+
+ TEST_LOG_FUNC();
+
+ /* Read the current value of PCR 16 ("debug"), so the prediction covers the actual TPM state. */
+ TPML_PCR_SELECTION selection;
+ tpm2_tpml_pcr_selection_from_mask(UINT32_C(1) << 16, TPM2_ALG_SHA256, &selection);
+
+ _cleanup_free_ Tpm2PCRValue *pcr_values = NULL;
+ size_t n_pcr_values = 0;
+ ASSERT_OK(tpm2_pcr_read(c, &selection, &pcr_values, &n_pcr_values));
+ ASSERT_EQ(n_pcr_values, 1u);
+ ASSERT_EQ(pcr_values[0].index, 16u);
+ ASSERT_EQ(pcr_values[0].hash, TPM2_ALG_SHA256);
+
+ /* 65 alternatives for PCR 16, the first of which is the current value, so the PolicyOR tree is three
+ * levels deep. */
+ _cleanup_(tpm2_pcr_prediction_done) Tpm2PCRPrediction prediction = {
+ .pcrs = UINT32_C(1) << 16,
+ };
+
+ for (size_t i = 0; i < 65; i++) {
+ _cleanup_free_ Tpm2PCRPredictionResult *result = new0(Tpm2PCRPredictionResult, 1);
+ ASSERT_NOT_NULL(result);
+
+ if (i == 0)
+ result->hash[sha256_index()] = pcr_values[0].value;
+ else
+ fill_indexed_digest(&result->hash[sha256_index()], i + 1);
+
+ ASSERT_OK(ordered_set_ensure_put(
+ &prediction.results[16],
+ &tpm2_pcr_prediction_result_hash_ops,
+ result));
+ TAKE_PTR(result);
+ }
+
+ ASSERT_EQ(ordered_set_size(prediction.results[16]), 65u);
+
+ /* Compute the digest the unsealing side pins. */
+ TPM2B_DIGEST expected = { .size = SHA256_DIGEST_SIZE };
+ ASSERT_OK(tpm2_calculate_policy_super_pcr(&prediction, TPM2_ALG_SHA256, &expected));
+
+ _cleanup_(tpm2_handle_freep) Tpm2Handle *session = NULL;
+ ASSERT_OK(tpm2_make_policy_session(
+ c,
+ /* primary= */ NULL,
+ /* encryption_session= */ NULL,
+ &session));
+ ASSERT_OK(tpm2_policy_super_pcr(c, session, &prediction, TPM2_ALG_SHA256));
+
+ _cleanup_(Esys_Freep) TPM2B_DIGEST *tpm_digest = NULL;
+ ASSERT_OK(tpm2_policy_auth_value(c, session, &tpm_digest));
+ ASSERT_OK(tpm2_calculate_policy_auth_value(&expected));
+
+ ASSERT_EQ(tpm_digest->size, expected.size);
+ ASSERT_EQ(memcmp(tpm_digest->buffer, expected.buffer, expected.size), 0);
+}
+
TEST_RET(tests_which_require_tpm) {
_cleanup_(tpm2_context_unrefp) Tpm2Context *c = NULL;
int r = 0;
@@ -2932,6 +3229,8 @@ TEST_RET(tests_which_require_tpm) {
check_nv_certify(c);
check_certify(c);
check_get_session_audit_digest(c);
+ check_policy_or_tree_runtime(c);
+ check_policy_super_pcr_tree(c);
#if HAVE_OPENSSL
r = check_calculate_seal(c);
diff --git a/test/units/TEST-70-TPM2.pcrlock.sh b/test/units/TEST-70-TPM2.pcrlock.sh
index 61018cd942f..1109b068300 100755
--- a/test/units/TEST-70-TPM2.pcrlock.sh
+++ b/test/units/TEST-70-TPM2.pcrlock.sh
@@ -46,7 +46,7 @@ at_exit() {
"$SD_PCRLOCK" unlock-raw --pcrlock=/var/lib/pcrlock.d/920-test70.pcrlock
fi
- rm -rf /tmp/fakexbootldr /var/lib/pcrlock.d/123-empty.pcrlock.d /run/systemd/system/systemd-pcrlock.socket.d
+ rm -rf /tmp/fakexbootldr /var/lib/pcrlock.d/123-empty.pcrlock.d /var/lib/pcrlock.d/930-many.pcrlock.d /run/systemd/system/systemd-pcrlock.socket.d
if [[ -n "${img:-}" ]]; then
rm -f "$img" "$img".private.pem "$img".public.pem "$img".pcrsign
fi
@@ -196,6 +196,30 @@ echo -n test70-take-two | "$SD_PCRLOCK" lock-raw --pcrlock=/var/lib/pcrlock.d/92
systemd-cryptsetup attach pcrlock "$img" - tpm2-device=auto,tpm2-pcrlock=/var/lib/systemd/pcrlock.json,headless
systemd-cryptsetup detach pcrlock
+# Now exercise the PolicyOR tree code path: more than 8 alternatives for a PCR. Add a component with 9
+# variants touching PCR 16, rebuild the policy (the current PCR 16 state still matches the policy generated
+# above, so the recovery PIN is not needed), then extend PCR 16 with the 9th variant and unlock again. A
+# flat PolicyOR could only hold 8 of these. The 9th variant is in the second group, so the nested walk also
+# has to submit a group slice with a non-zero start offset.
+mkdir -p /var/lib/pcrlock.d/930-many.pcrlock.d
+for i in $(seq -w 1 9); do
+ echo -n "test70-many-$i" | "$SD_PCRLOCK" lock-raw \
+ --pcrlock="/var/lib/pcrlock.d/930-many.pcrlock.d/${i}.pcrlock" --pcr=16
+done
+
+"$SD_PCRLOCK" predict --pcr="$PCRS"
+
+# The extra variants for PCR 16 must actually end up in the prediction, otherwise this block silently stops
+# covering the nested PolicyOR code path.
+"$SD_PCRLOCK" predict --json=short --pcr="$PCRS" | \
+ jq -e '[to_entries[] | .value[] | select(.pcr == 16) | .values | length] | length > 0 and all(. > 8)' >/dev/null
+
+"$SD_PCRLOCK" make-policy --pcr="$PCRS" --force
+
+"$SD_PCREXTEND" --pcr=16 test70-many-9
+systemd-cryptsetup attach pcrlock "$img" - tpm2-device=auto,tpm2-pcrlock=/var/lib/systemd/pcrlock.json,headless
+systemd-cryptsetup detach pcrlock
+
# Now combined pcrlock and signed PCR
# Generate key pair
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$img".private.pem