report: Support signing reports with multiple TPM2 keys

In preparation for adding a varlink interface for key management, add
support for signing reports with multiple TPM2 keys. During signing,
this iterates over all *.key files in /var/lib/systemd/report.sign.tpm2,
generating a TPM signature with each one. It also looks for the
corresponding *.voucher file, containing some credential that is used to
prove that the signing key is authentic, and which is added to the
signature if it exists.

If no signing keys exist when a signature is requested, a default
signing key is still created as default.key.
This commit is contained in:
Chris Coulson
2026-09-24 09:28:38 +02:00
committed by Luca Boccassi
parent 2d6bc0328c
commit 1c5783b112
2 changed files with 634 additions and 229 deletions
+309 -134
View File
@@ -5,7 +5,9 @@
#include "sd-json.h"
#include "sd-varlink.h"
#include "alloc-util.h"
#include "build.h"
#include "dirent-util.h"
#include "dlopen-note.h"
#include "fd-util.h"
#include "fileio.h"
@@ -16,6 +18,7 @@
#include "main-func.h"
#include "options.h"
#include "string-table.h"
#include "string-util.h"
#include "strv.h"
#include "tmpfile-util.h"
#include "tpm2-pcr.h"
@@ -35,22 +38,24 @@ COMMAND(
#define REPORT_SIGN_TPM2_PERSISTENT_DIR "/var/lib/systemd/report.sign.tpm2"
#define REPORT_SIGN_TPM2_RUNTIME_DIR "/run/systemd/report.sign.tpm2"
/* File containing the TPM signing key data. */
#define REPORT_SIGN_TPM2_KEY "default.key"
#define REPORT_SIGN_TPM2_KEY_DEFAULT_NAME "default"
/* The voucher is some credential optionally issued as part of onboarding in order to certify the
* attestation key, and which is attached to the report signature. It's intentionally not "certificate" as
* we don't prescribe that it has to be a X.509 certificate - it's entirely opaque to us and an implementation
* detail of whatever platform we onboard with. It's also not "cred" or "credential" because those terms
* already have other meanings in systemd. */
#define REPORT_SIGN_TPM2_VOUCHER "default.voucher"
/* Files containing the TPM signing key data. */
#define REPORT_SIGN_TPM2_KEY_EXT ".key"
/* The key context contains the TPM context blob generated by TPM2_ContextSave, and
* is persisted between signing invocations for ordinary and primary signing keys.
* This avoids having to recreate primary keys, or reload ordinary keys (which requires
* authorization with the parent storage key, and which requires a policy session in
* some circumstances. */
#define REPORT_SIGN_TPM2_KEYCONTEXT "default.context"
/* Files containing an optional voucher for a signing key. The voucher is some credential optionally
* issued as part of onboarding in order to certify the corresponding attestation key, and which is attached
* to the report signature. It's intentionally not "certificate" as we don't prescribe that it has to be a
* X.509 certificate - it's entirely opaque to us and an implementation detail of whatever platform we
* onboard with. It's also not "cred" or "credential" because those terms already have other meanings in
* systemd. */
#define REPORT_SIGN_TPM2_VOUCHER_EXT ".voucher"
/* Files containing a saved TPM key context. The context contains the TPM context blob generated by
* TPM2_ContextSave, and is persisted between signing invocations for ordinary and primary signing keys.
* This avoids having to recreate primary keys, or reload ordinary keys (which requires authorization
* with the parent storage key, and which requires a policy session in some circumstances. */
#define REPORT_SIGN_TPM2_KEYCONTEXT_EXT ".context"
typedef struct SignRequestParameters {
struct iovec digest;
@@ -117,6 +122,7 @@ DEFINE_PRIVATE_STRING_TABLE_LOOKUP(signing_key_type, SigningKeyType);
static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_signing_key_type, SigningKeyType, signing_key_type_from_string);
/* SigningKeyData represents the stored data corresponding to a TPM signing key. */
typedef struct SigningKeyData {
SigningKeyType type;
@@ -130,8 +136,9 @@ typedef struct SigningKeyData {
struct iovec template;
} SigningKeyData;
static void signing_key_data_done(SigningKeyData *d) {
assert(d);
static SigningKeyData *signing_key_data_free(SigningKeyData *d) {
if (!d)
return NULL;
iovec_done(&d->parent_handle);
iovec_done(&d->public);
@@ -140,20 +147,27 @@ static void signing_key_data_done(SigningKeyData *d) {
iovec_done(&d->handle);
iovec_done(&d->template);
return mfree(d);
}
static int load_signing_key_data(int dir_fd, SigningKeyData *ret) {
DEFINE_TRIVIAL_CLEANUP_FUNC(SigningKeyData*, signing_key_data_free);
static int load_signing_key_data(int dir_fd, const char *fname, SigningKeyData *ret) {
int r;
assert(dir_fd >= 0);
assert(fname);
assert(ret);
_cleanup_free_ char *dir_path = NULL;
(void) fd_get_path(dir_fd, &dir_path);
_cleanup_fclose_ FILE *f = NULL;
r = xfopenat(dir_fd, REPORT_SIGN_TPM2_KEY, "re", /* open_flags= */ 0, &f);
if (r == -ENOENT)
return r;
r = xfopenat(dir_fd, fname, "re", /* open_flags= */ 0, &f);
if (r < 0)
return log_error_errno(r, "Failed to open TPM signing key data: %m");
return log_error_errno(r, "Failed to open TPM signing key data '%s/%s': %m",
strnull(dir_path), fname);
_cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL;
r = sd_json_parse_file(
@@ -164,7 +178,8 @@ static int load_signing_key_data(int dir_fd, SigningKeyData *ret) {
/* reterr_line= */ NULL,
/* reterr_column= */ NULL);
if (r < 0)
return log_error_errno(r, "Failed to load TPM signing key data: %m");
return log_error_errno(r, "Failed to load TPM signing key data from '%s/%s': %m",
strnull(dir_path), fname);
static const sd_json_dispatch_field dispatch_table[] = {
{ "type", SD_JSON_VARIANT_STRING, json_dispatch_signing_key_type, offsetof(SigningKeyData, type), SD_JSON_MANDATORY },
@@ -177,16 +192,16 @@ static int load_signing_key_data(int dir_fd, SigningKeyData *ret) {
{},
};
_cleanup_(signing_key_data_done) SigningKeyData data = {};
r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS, &data);
r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS, ret);
if (r < 0)
return r;
return log_error_errno(r, "Failed to dispatch TPM signing key data from '%s/%s': %m",
strnull(dir_path), fname);
*ret = TAKE_STRUCT(data);
return 1;
log_debug("Loaded TPM signing key data from '%s/%s'.", strnull(dir_path), fname);
return 0;
}
/* Generate an ordinary attestation key using the best available template. */
static int generate_key(Tpm2Context *c, int dir_fd) {
int r;
@@ -241,12 +256,17 @@ static int generate_key(Tpm2Context *c, int dir_fd) {
if (r < 0)
return log_error_errno(r, "Failed to build signing key data: %m");
_cleanup_free_ char *dir_path = NULL;
(void) fd_get_path(dir_fd, &dir_path);
const char *fname = REPORT_SIGN_TPM2_KEY_DEFAULT_NAME REPORT_SIGN_TPM2_KEY_EXT;
_cleanup_fclose_ FILE *f = NULL;
_cleanup_free_ char *temp = NULL;
r = fopen_tmpfile_linkable_at(dir_fd, REPORT_SIGN_TPM2_KEY, O_WRONLY, &temp, &f);
r = fopen_tmpfile_linkable_at(dir_fd, fname, O_WRONLY, &temp, &f);
if (r < 0)
return log_error_errno(r, "Failed to open signing key data file '%s/%s' for writing: %m",
REPORT_SIGN_TPM2_PERSISTENT_DIR, REPORT_SIGN_TPM2_KEY);
strnull(dir_path), fname);
CLEANUP_TMPFILE_AT(dir_fd, temp);
@@ -254,16 +274,179 @@ static int generate_key(Tpm2Context *c, int dir_fd) {
if (r < 0)
return log_error_errno(r, "Failed to write signing key data: %m");
r = flink_tmpfile_at(f, dir_fd, temp, REPORT_SIGN_TPM2_KEY, LINK_TMPFILE_SYNC);
r = flink_tmpfile_at(f, dir_fd, temp, fname, LINK_TMPFILE_SYNC);
if (r < 0)
return log_error_errno(r, "Failed to move signing key data file '%s/%s' into place: %m",
REPORT_SIGN_TPM2_PERSISTENT_DIR, REPORT_SIGN_TPM2_KEY);
strnull(dir_path), fname);
temp = mfree(temp);
log_debug("Generated signing key and saved to '%s/%s'.", strnull(dir_path), fname);
return 0;
}
typedef struct SigningKey {
char *name;
SigningKeyData *data;
struct iovec voucher;
} SigningKey;
static void signing_key_done(SigningKey *k) {
assert(k);
k->name = mfree(k->name);
k->data = signing_key_data_free(k->data);
iovec_done(&k->voucher);
}
static void signing_key_array_free(SigningKey *keys, size_t n_keys) {
assert(keys || n_keys == 0);
FOREACH_ARRAY(k, keys, n_keys)
signing_key_done(k);
mfree(keys);
}
/* Load a single signing key and its optional voucher. */
static int load_signing_key(int dir_fd, const char *key_fname, SigningKey *ret) {
int r;
assert(dir_fd >= 0);
assert(key_fname);
assert(ret);
const char *e = endswith(key_fname, REPORT_SIGN_TPM2_KEY_EXT);
assert(e);
_cleanup_free_ char *name = strndup(key_fname, e - key_fname);
if (!name)
return log_oom();
_cleanup_(signing_key_data_freep) SigningKeyData *data = new0(SigningKeyData, 1);
if (!data)
return log_oom();
r = load_signing_key_data(dir_fd, key_fname, data);
if (r < 0)
return r;
_cleanup_free_ char *dir_path = NULL;
(void) fd_get_path(dir_fd, &dir_path);
_cleanup_free_ char *voucher_fname = strjoin(name, REPORT_SIGN_TPM2_VOUCHER_EXT);
if (!voucher_fname)
return log_oom();
_cleanup_(iovec_done) struct iovec voucher = {};
_cleanup_free_ char *voucher_buf = NULL;
size_t voucher_sz = 0;
r = read_full_file_at(dir_fd, voucher_fname, &voucher_buf, &voucher_sz);
if (r < 0 && r != -ENOENT)
return log_error_errno(r, "Failed to read signing key voucher '%s/%s': %m",
strnull(dir_path), voucher_fname);
if (r >= 0) {
log_debug("Loaded signing key voucher from '%s/%s'.", strnull(dir_path), voucher_fname);
voucher = IOVEC_MAKE(TAKE_PTR(voucher_buf), voucher_sz);
}
*ret = (SigningKey) {
.name = TAKE_PTR(name),
.data = TAKE_PTR(data),
.voucher = TAKE_STRUCT(voucher),
};
return 0;
}
/* Load all signing keys and their optional vouchers.
*
* If there are no existing signing keys, then one is created automatically as an ordinary child of the EK,
* unless a voucher for the default key already exists.
*
* It might also be nice to have some way of customizing what type of signing key is automatically created
* if one doesn't exist. */
static int load_signing_keys(Tpm2Context *c, SigningKey **ret_keys, size_t *ret_n_keys) {
int r;
assert(c);
assert(ret_keys);
assert(ret_n_keys);
/* Open (creating if necessary) and exclusively lock the key directory, so that loading/generating the
* key is safe against parallel invocations (we are spawned once per connection). Like with the plain
* signer, the directory has 0700 permissions but this is just to be conservative - there's no
* unencrypted key material here. */
_cleanup_close_ int dir_fd = xopenat_lock_full(
AT_FDCWD,
REPORT_SIGN_TPM2_PERSISTENT_DIR,
O_CLOEXEC|O_DIRECTORY|O_CREAT,
/* xopen_flags= */ 0,
/* mode= */ 0700,
LOCK_BSD,
LOCK_EX);
if (dir_fd < 0)
return log_error_errno(dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_TPM2_PERSISTENT_DIR);
SigningKey *keys = NULL;
size_t n_keys = 0;
CLEANUP_ARRAY(keys, n_keys, signing_key_array_free);
_cleanup_closedir_ DIR *d = xopendirat(dir_fd, "", O_CLOEXEC);
if (!d)
return log_error_errno(errno, "Failed to open directory '%s': %m", REPORT_SIGN_TPM2_PERSISTENT_DIR);
FOREACH_DIRENT(de, d, return log_error_errno(errno, "Failed to read directory '%s': %m", REPORT_SIGN_TPM2_PERSISTENT_DIR)) {
if (!dirent_is_file_with_suffix(de, REPORT_SIGN_TPM2_KEY_EXT))
continue;
if (!GREEDY_REALLOC(keys, n_keys + 1))
return log_oom();
r = load_signing_key(dir_fd, de->d_name, keys + n_keys);
if (r < 0)
return r;
n_keys++;
}
/* If there are no keys yet, generate a default one and load it. */
if (n_keys == 0) {
/* This shouldn't be a scenario that happens in practice, unless somebody erased default.key.
* But nevertheless, if we get here with no signing keys but there is a default.voucher, we
* refuse to generate the default key. */
const char *voucher_fname = REPORT_SIGN_TPM2_KEY_DEFAULT_NAME REPORT_SIGN_TPM2_VOUCHER_EXT;
if (faccessat(dir_fd, voucher_fname, F_OK, AT_SYMLINK_NOFOLLOW) >= 0)
return log_error_errno(SYNTHETIC_ERRNO(ENOENT),
"Voucher '%s/%s' exists but its signing key does not, refusing to generate a default signing key.",
REPORT_SIGN_TPM2_PERSISTENT_DIR, voucher_fname);
if (errno != ENOENT)
return log_error_errno(errno, "Failed to check whether voucher '%s/%s' exists: %m",
REPORT_SIGN_TPM2_PERSISTENT_DIR, voucher_fname);
log_debug("No existing signing keys, generating a default signing key.");
r = generate_key(c, dir_fd);
if (r < 0)
return r;
if (!GREEDY_REALLOC(keys, n_keys + 1))
return log_oom();
r = load_signing_key(dir_fd, REPORT_SIGN_TPM2_KEY_DEFAULT_NAME REPORT_SIGN_TPM2_KEY_EXT, keys + n_keys);
if (r < 0)
return r;
n_keys++;
}
*ret_keys = TAKE_PTR(keys);
*ret_n_keys = n_keys;
return 0;
}
/* Recreate a primary TPM key in the specified hierarchy, using the supplied template data. */
static int recreate_primary_key(Tpm2Context *c, uint32_t hierarchy, const struct iovec *template, Tpm2Handle **ret_handle) {
int r;
@@ -307,6 +490,7 @@ static int recreate_primary_key(Tpm2Context *c, uint32_t hierarchy, const struct
return 0;
}
/* Acquire a handle for a persistent TPM key using the supplied persistent handle data. */
static int acquire_persistent_key(Tpm2Context *c, const struct iovec *handle_data, Tpm2Handle **ret_handle) {
int r;
@@ -342,6 +526,7 @@ static int acquire_persistent_key(Tpm2Context *c, const struct iovec *handle_dat
return 0;
}
/* Load an ordinary TPM key using the supplied parent handle data, and public and private areas. */
static int load_ordinary_key(
Tpm2Context *c,
const struct iovec *parent_handle_data,
@@ -391,162 +576,129 @@ static int load_ordinary_key(
return 0;
}
static int load_key_context(Tpm2Context *c, int dir_fd, Tpm2Handle **ret_handle) {
/* Try to load a TPM context for the specified name from the supplied directory. */
static int load_key_context(Tpm2Context *c, int dir_fd, const char *name, Tpm2Handle **ret) {
int r;
assert(c);
assert(dir_fd >= 0);
assert(ret_handle);
assert(name);
assert(ret);
_cleanup_free_ char *fname = strjoin(name, REPORT_SIGN_TPM2_KEYCONTEXT_EXT);
if (!fname)
return log_oom();
_cleanup_free_ char *dir_path = NULL;
(void) fd_get_path(dir_fd, &dir_path);
_cleanup_free_ char *buf = NULL;
size_t buf_sz;
r = read_full_file_at(dir_fd, REPORT_SIGN_TPM2_KEYCONTEXT, &buf, &buf_sz);
r = read_full_file_at(dir_fd, fname, &buf, &buf_sz);
if (r == -ENOENT)
return r;
if (r < 0)
return log_error_errno(r, "Failed to read saved signing key context: %m");
return log_error_errno(r, "Failed to read saved signing key context from '%s/%s': %m",
strnull(dir_path), fname);
TPMS_CONTEXT context;
r = tpm2_unmarshal_saved_handle_context(buf, buf_sz, &context);
if (r < 0)
return log_error_errno(r, "Failed to unmarshal saved signing key context: %m");
return log_error_errno(r, "Failed to unmarshal saved signing key context '%s': %m", name);
_cleanup_(tpm2_handle_freep) Tpm2Handle *handle = NULL;
r = tpm2_load_saved_handle_context(c, &context, /* ret_name= */ NULL, &handle);
if (r < 0)
return log_error_errno(r, "Failed to load saved signing key context: %m");
return log_error_errno(r, "Failed to load saved signing key context '%s': %m", name);
log_debug("Acquired signing key handle from saved context.");
log_debug("Acquired signing key handle from saved context at '%s/%s.", strnull(dir_path), fname);
*ret_handle = TAKE_PTR(handle);
*ret = TAKE_PTR(handle);
return 1;
return 0;
}
static int save_key_context(Tpm2Context *c, int dir_fd, const Tpm2Handle *handle) {
/* Save the TPM context for the key at the supplied handle to the specified directory. */
static int save_key_context(Tpm2Context *c, int dir_fd, const char *name, const Tpm2Handle *handle) {
int r;
assert(c);
assert(dir_fd >= 0);
assert(name);
assert(handle);
_cleanup_free_ char *fname = strjoin(name, REPORT_SIGN_TPM2_KEYCONTEXT_EXT);
if (!fname)
return log_oom();
_cleanup_(Esys_Freep) TPMS_CONTEXT *context = NULL;
r = tpm2_save_handle_context(c, handle, &context);
if (r < 0)
return log_debug_errno(r, "Failed to save signing key context: %m");
return log_debug_errno(r, "Failed to save signing key context '%s': %m", name);
_cleanup_free_ void *buf = NULL;
size_t buf_sz;
r = tpm2_marshal_saved_handle_context(context, &buf, &buf_sz);
if (r < 0)
return log_debug_errno(r, "Failed to marshal signing key context: %m");
return log_debug_errno(r, "Failed to marshal signing key context '%s': %m", name);
_cleanup_free_ char *dir_path = NULL;
(void) fd_get_path(dir_fd, &dir_path);
r = write_data_file_atomic_at(
dir_fd,
REPORT_SIGN_TPM2_KEYCONTEXT,
fname,
&IOVEC_MAKE(buf, buf_sz),
/* flags= */ 0);
if (r < 0)
return log_debug_errno(r, "Failed to write saved signing key context to '%s/%s': %m",
REPORT_SIGN_TPM2_RUNTIME_DIR, REPORT_SIGN_TPM2_KEYCONTEXT);
return log_error_errno(r, "Failed to write saved signing key context to '%s/%s': %m",
strnull(dir_path), fname);
log_debug("Saved signing key context to '%s/%s'.", REPORT_SIGN_TPM2_RUNTIME_DIR, REPORT_SIGN_TPM2_KEYCONTEXT);
log_debug("Saved signing key context to '%s/%s'.", strnull(dir_path), fname);
return 0;
}
/* Acquire the signing key handle. This can acquire several types of signing key:
/* Acquire a signing key handle. This can acquire several types of signing key:
* - An ordinary object that is protected by an existing persistent object (like the EK or SRK). These are
* loaded from a saved context if one exists, otherwise falling back to doing TPM2_Load again.
* - A persistent object.
* - A primary object that is created from a template on each use.
*
* If there is no existing signing key, then one is created automatically as an ordinary child of the EK.
*
* In the future, it would be nice to support multiple signing keys so that one can generate reports for
* different verifiers. Perhaps each signing key could have a corresponding set of report options rather
* than hardcoding the options as we do at the moment.
*
* It might also be nice to have some way of customizing what type of signing key is automatically created if
* one doesn't exist. */
static int acquire_key_handle(Tpm2Context *c, Tpm2Handle **ret_handle, struct iovec *ret_voucher) {
* - A primary object that is created from a template on each use. */
static int acquire_key_handle(Tpm2Context *c, int runtime_dir_fd, const char *name, const SigningKeyData *data, Tpm2Handle **ret) {
int r;
assert(c);
assert(ret_handle);
assert(ret_voucher);
/* Open (creating if necessary) and exclusively lock the key directory, so that loading/generating the
* key is safe against parallel invocations (we are spawned once per connection). Like with the plain
* signer, the directory has 0700 permissions but this is just to be conservative - there's no
* unencrypted key material here. */
_cleanup_close_ int dir_fd = xopenat_lock_full(
AT_FDCWD,
REPORT_SIGN_TPM2_PERSISTENT_DIR,
O_CLOEXEC|O_DIRECTORY|O_CREAT,
/* xopen_flags= */ 0,
/* mode= */ 0700,
LOCK_BSD,
LOCK_EX);
if (dir_fd < 0)
return log_error_errno(dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_TPM2_PERSISTENT_DIR);
/* Also open the runtime directory which we use for loading and saving TPM key contexts. */
_cleanup_close_ int runtime_dir_fd = xopenat_lock_full(
AT_FDCWD,
REPORT_SIGN_TPM2_RUNTIME_DIR,
O_CLOEXEC|O_DIRECTORY|O_CREAT,
/* xopen_flags = */ 0,
/* mode= */ 0700,
LOCK_BSD,
LOCK_EX);
if (runtime_dir_fd < 0)
return log_error_errno(runtime_dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_TPM2_RUNTIME_DIR);
_cleanup_free_ char *voucher = NULL;
size_t voucher_sz = 0;
r = read_full_file_at(dir_fd, REPORT_SIGN_TPM2_VOUCHER, &voucher, &voucher_sz);
if (r < 0 && r != -ENOENT)
return log_error_errno(r, "Failed to read signing key voucher: %m");
_cleanup_(signing_key_data_done) SigningKeyData data = {};
r = load_signing_key_data(dir_fd, &data);
if (r == -ENOENT && !voucher) {
r = generate_key(c, dir_fd);
if (r < 0)
return log_error_errno(r, "Failed to generate key: %m");
r = load_signing_key_data(dir_fd, &data);
}
if (r < 0)
return r;
assert(runtime_dir_fd >= 0);
assert(name);
assert(data);
assert(ret);
_cleanup_(tpm2_handle_freep) Tpm2Handle *handle = NULL;
switch (data.type) {
switch (data->type) {
case SIGNING_KEY_ORDINARY:
r = load_key_context(c, runtime_dir_fd, &handle);
r = load_key_context(c, runtime_dir_fd, name, &handle);
if (r == -ENOENT) {
r = load_ordinary_key(c, &data.parent_handle, &data.public, &data.private, &handle);
r = load_ordinary_key(c, &data->parent_handle, &data->public, &data->private, &handle);
if (r < 0)
return r;
(void) save_key_context(c, runtime_dir_fd, handle);
(void) save_key_context(c, runtime_dir_fd, name, handle);
} else if (r < 0)
return r;
break;
case SIGNING_KEY_PERSISTENT:
r = acquire_persistent_key(c, &data.handle, &handle);
r = acquire_persistent_key(c, &data->handle, &handle);
if (r < 0)
return r;
break;
case SIGNING_KEY_PRIMARY:
r = load_key_context(c, runtime_dir_fd, &handle);
r = load_key_context(c, runtime_dir_fd, name, &handle);
if (r == -ENOENT) {
r = recreate_primary_key(c, data.hierarchy, &data.template, &handle);
r = recreate_primary_key(c, data->hierarchy, &data->template, &handle);
if (r < 0)
return r;
(void) save_key_context(c, runtime_dir_fd, handle);
(void) save_key_context(c, runtime_dir_fd, name, handle);
} else if (r < 0)
return r;
break;
@@ -554,16 +706,14 @@ static int acquire_key_handle(Tpm2Context *c, Tpm2Handle **ret_handle, struct io
assert_not_reached();
}
*ret_handle = TAKE_PTR(handle);
*ret_voucher = IOVEC_MAKE(TAKE_PTR(voucher), voucher_sz);
*ret = TAKE_PTR(handle);
return 0;
}
static int reply_report(sd_varlink *link, const Tpm2Report *report, const struct iovec *voucher) {
static int build_report(sd_json_variant **v, const Tpm2Report *report, const struct iovec *voucher) {
int r;
assert(link);
assert(v);
assert(report);
assert(iovec_is_valid(voucher));
@@ -632,17 +782,13 @@ static int reply_report(sd_varlink *link, const Tpm2Report *report, const struct
return log_error_errno(r, "Cannot add component to report array: %m");
}
return sd_varlink_replybo(
link,
SD_JSON_BUILD_PAIR(
"data",
SD_JSON_BUILD_ARRAY(
SD_JSON_BUILD_OBJECT(
return sd_json_variant_append_arraybo(
v,
SD_JSON_BUILD_PAIR_VARIANT("eventLog", report->event_log),
SD_JSON_BUILD_PAIR_VARIANT("publicKey", pubkey),
SD_JSON_BUILD_PAIR_STRING("publicKeyPEM", pubkey_pem),
SD_JSON_BUILD_PAIR_CONDITION(iovec_is_set(voucher), "voucher", JSON_BUILD_IOVEC_BASE64(voucher)),
SD_JSON_BUILD_PAIR_VARIANT("components", cv)))));
SD_JSON_BUILD_PAIR_VARIANT("components", cv));
}
static int vl_method_sign(
@@ -692,19 +838,48 @@ static int vl_method_sign(
if (r < 0)
return r;
/* Load a signing key. */
_cleanup_(tpm2_handle_freep) Tpm2Handle *key_handle = NULL;
_cleanup_(iovec_done) struct iovec key_voucher = {};
r = acquire_key_handle(c, &key_handle, &key_voucher);
/* Open (creating if necessary) and exclusively lock the runtime directory which we use for loading
* and saving TPM key contexts. The exclusive lock ensures we serialize parallel invocations, as
* the report signing requires TPM exclusivity. */
_cleanup_close_ int runtime_dir_fd = xopenat_lock_full(
AT_FDCWD,
REPORT_SIGN_TPM2_RUNTIME_DIR,
O_CLOEXEC|O_DIRECTORY|O_CREAT,
/* xopen_flags = */ 0,
/* mode= */ 0700,
LOCK_BSD,
LOCK_EX);
if (runtime_dir_fd < 0)
return log_error_errno(runtime_dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_TPM2_RUNTIME_DIR);
/* Load all signing keys. */
SigningKey *keys = NULL;
size_t n_keys = 0;
CLEANUP_ARRAY(keys, n_keys, signing_key_array_free);
r = load_signing_keys(c, &keys, &n_keys);
if (r < 0)
return log_error_errno(r, "Failed to acquire a signing key handle: %m");
return log_error_errno(r, "Failed to load any signing keys: %m");
/* Generate a signed TPM report for each signing key. */
_cleanup_(sd_json_variant_unrefp) sd_json_variant *dv = NULL;
FOREACH_ARRAY(key, keys, n_keys) {
_cleanup_(tpm2_handle_freep) Tpm2Handle *key_handle = NULL;
r = acquire_key_handle(c, runtime_dir_fd, key->name, key->data, &key_handle);
if (r < 0)
return log_error_errno(r, "Failed to acquire a signing key handle for key '%s': %m", key->name);
_cleanup_(tpm2_report_freep) Tpm2Report *report = NULL;
r = tpm2_generate_report(c, &opts, key_handle, &external_data, &report);
if (r < 0)
return log_error_errno(r, "Failed to generate TPM report: %m");
return log_error_errno(r, "Failed to generate TPM report with key '%s': %m", key->name);
return reply_report(link, report, &key_voucher);
r = build_report(&dv, report, &key->voucher);
if (r < 0)
return log_error_errno(r, "Failed to build TPM report JSON for key '%s': %m", key->name);
}
return sd_varlink_replybo(link, SD_JSON_BUILD_PAIR_VARIANT("data", dv));
}
static int vl_server(void) {
+291 -61
View File
@@ -9,15 +9,18 @@ set -o pipefail
# TEST-74-AUX-UTILS.report.sh; it lives here because it needs a real TPM, which
# only the TPM2 integration test provides.
#
# The TPM2 backend returns a set of signed TPM attestations (a PCR quote, one
# NV certification per NvPCR, and a session audit digest), together with the
# signing key's public area and the pcrlock event log. The public area, the
# attestations and the signatures are all serialized as TCG TSS2 JSON. For each
# attestation we rebuild the public key, re-marshal the TPMS_ATTEST that was
# signed, and verify the signature using the embedded Python helper below. The
# helper also cross-checks the parallel PEM encodings (publicKeyPEM and signaturePEM)
# against the JSON encodings. We also confirm the report digest is carried in the
# extraData field of the session audit attestation.
# The TPM2 backend signs each report with every signing key it has been
# provisioned with (generating a default one if it has none), returning one
# signature record per key. Each record carries a set of signed TPM attestations
# (a PCR quote, one NV certification per NvPCR, and a session audit digest),
# together with the signing key's public area, the optional voucher for it and
# the pcrlock event log. The public area, the attestations and the signatures are
# all serialized as TCG TSS2 JSON. For each attestation we rebuild the public key,
# re-marshal the TPMS_ATTEST that was signed, and verify the signature using the
# embedded Python helper below. The helper also cross-checks the parallel PEM
# encodings (publicKeyPEM and signaturePEM) against the JSON encodings. We also
# confirm the report digest is carried in the extraData field of the session audit
# attestation.
#
# shellcheck source=test/units/util.sh
. "$(dirname "$0")"/util.sh
@@ -48,8 +51,34 @@ fi
WORK="$(mktemp -d)"
# Where the backend keeps its keys and its cached key contexts.
KEY_DIR="/var/lib/systemd/report.sign.tpm2"
CONTEXT_DIR="/run/systemd/report.sign.tpm2"
# Remove all keys, cached key contexts and vouchers, so each test starts fresh.
reset_state() {
rm -f "$KEY_DIR"/* "$CONTEXT_DIR"/* 2>/dev/null || true
}
# Print an identity for each named cached key context that changes whenever the
# file is rewritten. The backend writes a context only when it had to load or
# recreate the key, and does so atomically via rename, so a context whose inode
# is unchanged across a signing request was taken from the cache.
#
# $@: key names.
context_ids() {
local name
for name in "$@"; do
stat -c '%n %i %Y' "$CONTEXT_DIR/$name.context"
done
}
at_exit() {
set +e
# Don't leave the keys we provisioned behind. A default key is generated
# again on the next signing request.
reset_state
systemctl stop systemd-report.socket systemd-report-sign-tpm2.socket
rm -rf "$WORK"
}
@@ -70,38 +99,11 @@ systemctl start systemd-pcrlock.socket
systemctl start systemd-report.socket
systemctl start systemd-report-sign-tpm2.socket
# Ask systemd-report to generate a *signed* report over Varlink. The reply
# carries the signed report as base64-encoded JSON-SEQ data.
varlinkctl call /run/systemd/io.systemd.Report io.systemd.Report.GenerateSigned \
'{"matches":["io.systemd.Manager.UnitsTotal"]}' | jq -r .reportData | base64 -d >"$WORK/report.seq"
# The first JSON-SEQ record is the report itself. This is exactly the byte
# sequence that got signed, including the leading record separator (0x1e) and
# the trailing newline, so 'head -n1' reproduces it verbatim.
head -n1 "$WORK/report.seq" >"$WORK/message.bin"
tr -d '\036' <"$WORK/message.bin" | jq -e '.mediaType == "application/vnd.io.systemd.report"' >/dev/null
# The remaining record(s) are signature objects, one per enabled backend. Pick
# out the one produced by the TPM2 backend (the plain backend may be enabled too).
sig_json=""
while IFS= read -r line; do
rec="$(echo "$line" | tr -d '\036')"
[[ -n "$rec" ]] || continue
if [[ "$(echo "$rec" | jq -r '.mechanism // empty')" == "tpm2" ]]; then
sig_json="$rec"
fi
done < <(tail -n +2 "$WORK/report.seq")
test -n "$sig_json"
[ "$(echo "$sig_json" | jq -r .mediaType)" = "application/vnd.io.systemd.report.signature" ]
# The sha256 recorded in the signature must match the digest of the report bytes.
report_digest="$(sha256sum "$WORK/message.bin" | cut -d' ' -f1)"
[ "$(echo "$sig_json" | jq -r .sha256)" = "$report_digest" ]
# Use a python script for verifying the report component signatures because we
# need to reconstruct the TPM2B_ATTEST bytes from the provided TPMS_ATTEST JSON
# encoding, and construct a public key from the provided TPMT_PUBLIC JSON encoding.
# It has two modes: "verify" checks a full report, and "make-template" marshals a
# JSON encoded TPMT_PUBLIC into a base64 encoded TPM2B_PUBLIC.
VERIFY="$WORK/verify-report-sig.py"
cat >"$VERIFY" <<'EOF'
#!/usr/bin/env python3
@@ -398,10 +400,21 @@ def check_session_audit(alg, doc, key_name):
def main():
report_digest = sys.argv[1]
mode = sys.argv[1]
full = json.load(sys.stdin)
data = full["data"]
if mode == "make-template":
# Marshal the JSON encoded TPMT_PUBLIC read from stdin as a base64
# encoded TPM2B_PUBLIC.
pub = json.load(sys.stdin)
print(base64.b64encode(marshal_bytes_tpm2b(marshal_tpmt_public(pub))).decode())
return
if mode != "verify":
sys.exit(f"unknown mode {mode!r}")
report_digest = sys.argv[2]
data = json.load(sys.stdin)
# Rebuild the key from the JSON public area.
key_json = build_pubkey(data["publicKey"])
@@ -469,9 +482,6 @@ if __name__ == "__main__":
main()
EOF
n_components="$(echo "$sig_json" | jq '.data.components | length')"
[ "$n_components" -gt 0 ]
# The backend attests every defined NvPCR. Fetch them via systemd-analyze so we
# know how many components to expect and can cross-check each one's name, index
# and priority below. Columns: name, nvindex (an unsigned integer), priority.
@@ -479,18 +489,90 @@ nvpcrs_json="$(systemd-analyze nvpcrs --json=short)"
expected_nvpcrs="$(echo "$nvpcrs_json" | jq 'length')"
[ "$expected_nvpcrs" -gt 0 ]
# Install a signing key that is recreated from a template on each use (ie, a
# primary key, here in the owner hierarchy). There's no interface for creating
# signing keys with specific parameters yet, so marshal the TPM2B_PUBLIC template
# ourselves and write out the key data file that the backend consumes.
#
# $1: key name (the key is installed as $KEY_DIR/$1.key).
# $2: ECC curve (eg, NIST_P256).
# $3: digest algorithm (eg, SHA256).
install_primary_key() {
local name="${1:?}" curve="${2:?}" hash="${3:?}" template
# objectAttributes is FIXEDTPM|FIXEDPARENT|SENSITIVEDATAORIGIN|USERWITHAUTH|
# ADMINWITHPOLICY|RESTRICTED|SIGN_ENCRYPT, ie, a restricted signing key, which
# is what attestation requires.
template="$(jq -nc --arg h "$hash" --arg c "$curve" \
'{type: "ECC",
nameAlg: $h,
objectAttributes: 327922,
parameters: {scheme: {scheme: "ECDSA", details: {hashAlg: $h}}, curveID: $c},
unique: {x: "", y: ""}}' | python3 "$VERIFY" make-template)"
# 1073741825 is TPM2_RH_OWNER.
jq -nc --arg t "$template" '{type: "primary", hierarchy: 1073741825, template: $t}' >"$KEY_DIR/$name.key"
}
# Ask systemd-report to generate a *signed* report over Varlink. Each TPM2
# signature record is written to $WORK/report.sig.N, one per configured signing
# key. Prints the sha256 of the exact report bytes that were signed.
generate_signed() {
rm -f "$WORK"/report.sig.*
# The reply carries the signed report as base64-encoded JSON-SEQ data.
varlinkctl call /run/systemd/io.systemd.Report io.systemd.Report.GenerateSigned \
'{"matches":["io.systemd.Manager.UnitsTotal"]}' | jq -r .reportData | base64 -d >"$WORK/report.seq"
# The first JSON-SEQ record is the report itself. This is exactly the byte
# sequence that got signed, including the leading record separator (0x1e) and
# the trailing newline, so 'head -n1' reproduces it verbatim.
head -n1 "$WORK/report.seq" >"$WORK/message.bin"
tr -d '\036' <"$WORK/message.bin" | jq -e '.mediaType == "application/vnd.io.systemd.report"' >/dev/null
# The remaining record(s) are signature objects, one per signature returned by
# an enabled backend. Write out the ones produced by the TPM2 backend, one
# per configured signing key.
local line rec n=0
while IFS= read -r line; do
rec="$(echo "$line" | tr -d '\036')"
[[ -n "$rec" ]] || continue
if [[ "$(jq -r '.mechanism // empty' <<<"$rec")" == "tpm2" ]]; then
echo "$rec" >"$WORK/report.sig.$n"
n=$((n + 1))
fi
done < <(tail -n +2 "$WORK/report.seq")
sha256sum "$WORK/message.bin" | cut -d' ' -f1
}
# Verify a single TPM2 signature record read from file $1.
#
# $1: signature record file.
# $2: report digest.
#
# Prints the JSON public key that produced it.
verify_tpm2_sig() {
local sig_file="$1" digest="$2" report
[ "$(jq -r .mediaType "$sig_file")" = "application/vnd.io.systemd.report.signature" ]
[ "$(jq -r .sha256 "$sig_file")" = "$digest" ]
report="$(jq -c .data "$sig_file")"
local n_components comp_types
n_components="$(jq '.components | length' <<<"$report")"
[ "$n_components" -gt 0 ]
# Verify every component signature and collect the component types.
echo "$sig_json" | python3 "$VERIFY" "$report_digest" >"$WORK/component-types"
python3 "$VERIFY" verify "$digest" <<<"$report" >"$WORK/component-types"
mapfile -t comp_types <"$WORK/component-types"
[ "${#comp_types[@]}" -eq "$n_components" ]
saw_pcr=0
saw_audit=0
n_nvpcr=0
local saw_pcr=0 saw_audit=0 n_nvpcr=0 i type comp name auth expected_nvpcr
for i in "${!comp_types[@]}"; do
type="${comp_types[$i]}"
comp="$(echo "$sig_json" | jq -c ".data.components[$i]")"
comp="$(jq -c ".components[$i]" <<<"$report")"
case "$type" in
pcr)
@@ -499,26 +581,26 @@ for i in "${!comp_types[@]}"; do
nvpcr)
n_nvpcr=$((n_nvpcr + 1))
# NvPCR components carry the readable name, the serialized NV public
# area, and the authenticated data digested into the attestation's
# qualifying data. Ensure they're populated.
name="$(echo "$comp" | jq -r '.["nvpcrName"] // empty')"
# NvPCR components carry the readable name, the serialized NV
# public area, and the authenticated data digested into the
# attestation's qualifying data. Ensure they're populated.
name="$(jq -r '.["nvpcrName"] // empty' <<<"$comp")"
[ -n "$name" ]
[ -n "$(echo "$comp" | jq -r '.["nvPublic"] // empty')" ]
[ -n "$(echo "$comp" | jq -r '.["authenticatedData"] // empty')" ]
[ -n "$(jq -r '.["nvPublic"] // empty' <<<"$comp")" ]
[ -n "$(jq -r '.["authenticatedData"] // empty' <<<"$comp")" ]
# The name must be one systemd-analyze knows about.
expected_nvpcr="$(echo "$nvpcrs_json" | jq -c --arg n "$name" '.[] | select(.name == $n)')"
expected_nvpcr="$(jq -c --arg n "$name" '.[] | select(.name == $n)' <<<"$nvpcrs_json")"
[ -n "$expected_nvpcr" ]
# The certified NV index must match the one systemd-analyze reports.
[ "$(echo "$comp" | jq -r '.nvPublic.nvIndex')" = "$(echo "$expected_nvpcr" | jq -r '.nvindex')" ]
[ "$(jq -r '.nvPublic.nvIndex' <<<"$comp")" = "$(jq -r '.nvindex' <<<"$expected_nvpcr")" ]
# authenticatedData is a JSON string carrying the NvPCR name and
# priority; both must match this component and systemd-analyze.
auth="$(echo "$comp" | jq -r '.authenticatedData')"
[ "$(echo "$auth" | jq -r '.name')" = "$name" ]
[ "$(echo "$auth" | jq -r '.priority')" = "$(echo "$expected_nvpcr" | jq -r '.priority')" ]
auth="$(jq -r '.authenticatedData' <<<"$comp")"
[ "$(jq -r '.name' <<<"$auth")" = "$name" ]
[ "$(jq -r '.priority' <<<"$auth")" = "$(jq -r '.priority' <<<"$expected_nvpcr")" ]
;;
session-audit)
saw_audit=1
@@ -530,3 +612,151 @@ done
[ "$saw_pcr" -eq 1 ]
[ "$saw_audit" -eq 1 ]
[ "$n_nvpcr" -eq "$expected_nvpcrs" ]
jq -Sc '.publicKey' <<<"$report"
}
# 1) With no signing keys provisioned, the backend generates a default one and
# signs the report with it.
test_default_key() {
local digest pub ctx_before
local -a sig_files
reset_state
digest="$(generate_signed)"
mapfile -t sig_files < <(find "$WORK" -maxdepth 1 -name 'report.sig.*' | sort)
# A single signing key means a single signature record.
[ "${#sig_files[@]}" -eq 1 ]
pub="$(verify_tpm2_sig "${sig_files[0]}" "$digest")"
# The generated key and its cached context must both use the default name.
test -e "$KEY_DIR/default.key"
test -e "$CONTEXT_DIR/default.context"
# We installed no voucher for it, so the signature must not carry one.
jq -e '.data.voucher == null' "${sig_files[0]}" >/dev/null
# Signing again must reuse the cached key context rather than load the key
# into the TPM again. The default key is an ordinary object protected by the
# EK, so this covers the cache path taken for those.
ctx_before="$(context_ids default)"
digest="$(generate_signed)"
mapfile -t sig_files < <(find "$WORK" -maxdepth 1 -name 'report.sig.*' | sort)
[ "${#sig_files[@]}" -eq 1 ]
# Same key, and the context was taken from the cache: a miss would have
# loaded the key again and rewritten the context.
[ "$(verify_tpm2_sig "${sig_files[0]}" "$digest")" = "$pub" ]
[ "$(context_ids default)" = "$ctx_before" ]
echo "OK: default key test"
}
test_default_key
# 2) With several signing keys provisioned, the report is signed with each of
# them, and each key has its own cached key context and its own voucher.
test_multiple_keys() {
local digest sig pub voucher ctx_before pub_p256="" pub_p384=""
local -a sig_files report_pubs
reset_state
install_primary_key key-a NIST_P256 SHA256
install_primary_key key-b NIST_P384 SHA384
# Only key-b gets a voucher, so we can tell that each signature carries the
# voucher belonging to the key that produced it, and only that one.
voucher="voucher for key-b"
printf '%s' "$voucher" >"$KEY_DIR/key-b.voucher"
digest="$(generate_signed)"
mapfile -t sig_files < <(find "$WORK" -maxdepth 1 -name 'report.sig.*' | sort)
# One signature record per configured signing key.
[ "${#sig_files[@]}" -eq 2 ]
# We provisioned our own keys, so no default key may have been generated.
test ! -e "$KEY_DIR/default.key"
# Each key gets its own cached key context, named after the key.
test -e "$CONTEXT_DIR/key-a.context"
test -e "$CONTEXT_DIR/key-b.context"
for sig in "${sig_files[@]}"; do
pub="$(verify_tpm2_sig "$sig" "$digest")"
# Each signature must have been produced by one of the keys we installed,
# which we can tell apart by their parameters.
case "$(jq -r '.parameters.curveID' <<<"$pub")" in
NIST_P256)
jq -e '.nameAlg == "SHA256" and .parameters.scheme.details.hashAlg == "SHA256"' <<<"$pub" >/dev/null
pub_p256="$pub"
# key-a has no voucher.
jq -e '.data.voucher == null' "$sig" >/dev/null
;;
NIST_P384)
jq -e '.nameAlg == "SHA384" and .parameters.scheme.details.hashAlg == "SHA384"' <<<"$pub" >/dev/null
pub_p384="$pub"
# key-b's voucher is attached verbatim, base64 encoded.
[ "$(jq -r '.data.voucher' "$sig")" = "$(printf '%s' "$voucher" | base64 -w0)" ]
;;
*)
echo "report signed by an unexpected key" >&2
return 1
;;
esac
done
# Both of the keys must have been used.
test -n "$pub_p256"
test -n "$pub_p384"
# Signing again must reuse the cached key contexts, and still produce one
# signature per key, signed by the same two keys. Both keys are primary
# objects recreated from a template, so this covers the cache path taken for
# those.
ctx_before="$(context_ids key-a key-b)"
digest="$(generate_signed)"
mapfile -t sig_files < <(find "$WORK" -maxdepth 1 -name 'report.sig.*' | sort)
[ "${#sig_files[@]}" -eq 2 ]
report_pubs=()
for sig in "${sig_files[@]}"; do
report_pubs+=("$(verify_tpm2_sig "$sig" "$digest")")
done
[ "$(printf '%s\n' "${report_pubs[@]}" | sort)" = "$(printf '%s\n' "$pub_p256" "$pub_p384" | sort)" ]
# A cache miss would have recreated both keys and rewritten their contexts.
[ "$(context_ids key-a key-b)" = "$ctx_before" ]
echo "OK: multiple keys test"
}
test_multiple_keys
# 3) A voucher for the default key with no matching key must not cause a default
# key to be generated. A voucher certifies the key it was issued for, but is
# paired to it by file name alone, so a generated key would end up shipping a
# voucher that certifies a different key.
test_voucher_without_key() {
reset_state
printf '%s' "voucher for a key we no longer have" >"$KEY_DIR/default.voucher"
# The backend refuses the request, which fails signing in requireAll mode.
(! varlinkctl call /run/systemd/io.systemd.Report io.systemd.Report.GenerateSigned \
'{"matches":["io.systemd.Manager.UnitsTotal"],"mode":"requireAll"}' >/dev/null)
# No key may have been generated for the stray voucher.
test ! -e "$KEY_DIR/default.key"
test ! -e "$CONTEXT_DIR/default.context"
echo "OK: voucher without key test"
}
test_voucher_without_key