diff --git a/src/report/report-sign-tpm2.c b/src/report/report-sign-tpm2.c index d94d15e94b..24f61067cb 100644 --- a/src/report/report-sign-tpm2.c +++ b/src/report/report-sign-tpm2.c @@ -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( - 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))))); + 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)); } 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); - if (r < 0) - return log_error_errno(r, "Failed to acquire a signing key handle: %m"); + /* 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); - _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"); + /* Load all signing keys. */ + SigningKey *keys = NULL; + size_t n_keys = 0; + CLEANUP_ARRAY(keys, n_keys, signing_key_array_free); - return reply_report(link, report, &key_voucher); + r = load_signing_keys(c, &keys, &n_keys); + if (r < 0) + 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 with key '%s': %m", key->name); + + 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) { diff --git a/test/units/TEST-70-TPM2.report-sign.sh b/test/units/TEST-70-TPM2.report-sign.sh index 21a7a1fd58..85994b7872 100755 --- a/test/units/TEST-70-TPM2.report-sign.sh +++ b/test/units/TEST-70-TPM2.report-sign.sh @@ -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,54 +489,274 @@ nvpcrs_json="$(systemd-analyze nvpcrs --json=short)" expected_nvpcrs="$(echo "$nvpcrs_json" | jq 'length')" [ "$expected_nvpcrs" -gt 0 ] -# Verify every component signature and collect the component types. -echo "$sig_json" | python3 "$VERIFY" "$report_digest" >"$WORK/component-types" -mapfile -t comp_types <"$WORK/component-types" -[ "${#comp_types[@]}" -eq "$n_components" ] +# 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 -saw_pcr=0 -saw_audit=0 -n_nvpcr=0 + # 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)" -for i in "${!comp_types[@]}"; do - type="${comp_types[$i]}" - comp="$(echo "$sig_json" | jq -c ".data.components[$i]")" + # 1073741825 is TPM2_RH_OWNER. + jq -nc --arg t "$template" '{type: "primary", hierarchy: 1073741825, template: $t}' >"$KEY_DIR/$name.key" +} - case "$type" in - pcr) - saw_pcr=1 - ;; - nvpcr) - n_nvpcr=$((n_nvpcr + 1)) +# 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.* - # 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')" - [ -n "$name" ] - [ -n "$(echo "$comp" | jq -r '.["nvPublic"] // empty')" ] - [ -n "$(echo "$comp" | jq -r '.["authenticatedData"] // empty')" ] + # 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 name must be one systemd-analyze knows about. - expected_nvpcr="$(echo "$nvpcrs_json" | jq -c --arg n "$name" '.[] | select(.name == $n)')" - [ -n "$expected_nvpcr" ] + # 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 certified NV index must match the one systemd-analyze reports. - [ "$(echo "$comp" | jq -r '.nvPublic.nvIndex')" = "$(echo "$expected_nvpcr" | jq -r '.nvindex')" ] + # 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") - # 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')" ] - ;; - session-audit) - saw_audit=1 - ;; - esac -done + sha256sum "$WORK/message.bin" | cut -d' ' -f1 +} -# Make sure we saw the expected components. -[ "$saw_pcr" -eq 1 ] -[ "$saw_audit" -eq 1 ] -[ "$n_nvpcr" -eq "$expected_nvpcrs" ] +# 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. + python3 "$VERIFY" verify "$digest" <<<"$report" >"$WORK/component-types" + mapfile -t comp_types <"$WORK/component-types" + [ "${#comp_types[@]}" -eq "$n_components" ] + + 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="$(jq -c ".components[$i]" <<<"$report")" + + case "$type" in + pcr) + saw_pcr=1 + ;; + 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="$(jq -r '.["nvpcrName"] // empty' <<<"$comp")" + [ -n "$name" ] + [ -n "$(jq -r '.["nvPublic"] // empty' <<<"$comp")" ] + [ -n "$(jq -r '.["authenticatedData"] // empty' <<<"$comp")" ] + + # The name must be one systemd-analyze knows about. + 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. + [ "$(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="$(jq -r '.authenticatedData' <<<"$comp")" + [ "$(jq -r '.name' <<<"$auth")" = "$name" ] + [ "$(jq -r '.priority' <<<"$auth")" = "$(jq -r '.priority' <<<"$expected_nvpcr")" ] + ;; + session-audit) + saw_audit=1 + ;; + esac + done + + # Make sure we saw the expected components. + [ "$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