From 951cfae10bd48bf907ae0c846332f44f1694245a Mon Sep 17 00:00:00 2001 From: Totoro Date: Sun, 6 Sep 2026 01:50:16 +0800 Subject: [PATCH] creds: decode Base64 when reading encrypted credentials Encrypted credentials produced by systemd-creds are stored as Base64. Decode them before decrypting in read_credential_with_decryption(), while leaving plaintext credentials unchanged and retaining their precedence. Bound the decoded encrypted input and report oversized credentials explicitly. Match the plaintext reader's string semantics by rejecting embedded NUL bytes when no size output is requested, while preserving binary payloads for callers that request their size. Add regression coverage for missing and malformed credentials, plaintext precedence, decoded size boundaries, single-line and wrapped Base64, empty strings, and binary data. Keep test outputs and host secrets scoped to private fixtures. Fixes #43621 --- src/shared/creds-util.c | 26 +++-- src/test/test-creds.c | 207 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) diff --git a/src/shared/creds-util.c b/src/shared/creds-util.c index d99369c809..7fbddf7a27 100644 --- a/src/shared/creds-util.c +++ b/src/shared/creds-util.c @@ -180,7 +180,7 @@ int read_credential(const char *name, void **ret, size_t *ret_size) { } int read_credential_with_decryption(const char *name, void **ret, size_t *ret_size) { - _cleanup_(iovec_done_erase) struct iovec ret_iovec = {}; + _cleanup_(iovec_done_erase) struct iovec decrypted = {}; _cleanup_(erase_and_freep) void *data = NULL; _cleanup_free_ char *fn = NULL; size_t sz = 0; @@ -218,16 +218,20 @@ int read_credential_with_decryption(const char *name, void **ret, size_t *ret_si if (!fn) return log_oom(); + /* Encrypted credentials are stored as Base64, unlike the plaintext credentials checked above. */ r = read_full_file_full( AT_FDCWD, fn, - UINT64_MAX, SIZE_MAX, - READ_FULL_FILE_SECURE, + UINT64_MAX, CREDENTIAL_ENCRYPTED_SIZE_MAX, + READ_FULL_FILE_SECURE|READ_FULL_FILE_UNBASE64|READ_FULL_FILE_FAIL_WHEN_LARGER, NULL, (char**) &data, &sz); if (r == -ENOENT) goto not_found; + if (r == -E2BIG) + return log_error_errno(r, "Encrypted credential '%s' exceeds the size limit.", name); if (r < 0) - return log_error_errno(r, "Failed to read encrypted credential data: %m"); + return log_error_errno( + r, "Failed to read or Base64-decode encrypted credential '%s': %m", name); if (geteuid() != 0) r = ipc_decrypt_credential( @@ -236,7 +240,7 @@ int read_credential_with_decryption(const char *name, void **ret, size_t *ret_si getuid(), &IOVEC_MAKE(data, sz), CREDENTIAL_ANY_SCOPE, - &ret_iovec); + &decrypted); else r = decrypt_credential_and_warn( name, @@ -246,14 +250,20 @@ int read_credential_with_decryption(const char *name, void **ret, size_t *ret_si getuid(), &IOVEC_MAKE(data, sz), CREDENTIAL_ANY_SCOPE, - &ret_iovec); + &decrypted); if (r < 0) return r; + /* Match read_credential(): without a size output, the caller expects a string. */ + if (!ret_size && decrypted.iov_len > 0 && memchr(decrypted.iov_base, 0, decrypted.iov_len)) + return log_error_errno( + SYNTHETIC_ERRNO(EBADMSG), + "Decrypted credential '%s' contains embedded NUL bytes, refusing.", name); + if (ret) - *ret = TAKE_PTR(ret_iovec.iov_base); + *ret = TAKE_PTR(decrypted.iov_base); if (ret_size) - *ret_size = ret_iovec.iov_len; + *ret_size = decrypted.iov_len; return 1; /* found */ diff --git a/src/test/test-creds.c b/src/test/test-creds.c index bd18e1c2c2..661a766502 100644 --- a/src/test/test-creds.c +++ b/src/test/test-creds.c @@ -1,13 +1,17 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include "alloc-util.h" #include "creds-util.h" +#include "env-util.h" #include "fileio.h" #include "format-util.h" #include "hexdecoct.h" #include "id128-util.h" #include "iovec-util.h" +#include "memory-util.h" #include "path-util.h" #include "rm-rf.h" +#include "string-util.h" #include "tests.h" #include "tmpfile-util.h" #include "tpm2-util.h" @@ -73,6 +77,209 @@ TEST(read_credential_strings) { ASSERT_OK_ERRNO(unsetenv("CREDENTIALS_DIRECTORY")); } +TEST(read_credential_with_decryption) { + static const struct iovec binary = CONST_IOVEC_MAKE_STRING("foo\0bar\n"); + _cleanup_(rm_rf_physical_and_freep) char *plain_dir = NULL, *encrypted_dir = NULL; + _cleanup_(rm_rf_physical_and_freep) char *secret_dir = NULL; + _cleanup_free_ char *saved_plain = NULL, *saved_encrypted = NULL, *saved_secret = NULL; + _cleanup_free_ char *plain_path = NULL, *encrypted_path = NULL, *secret_path = NULL; + void *missing = POINTER_MAX; + size_t missing_size = SIZE_MAX; + + ASSERT_OK(free_and_strdup(&saved_plain, getenv("CREDENTIALS_DIRECTORY"))); + ASSERT_OK(free_and_strdup(&saved_encrypted, getenv("ENCRYPTED_CREDENTIALS_DIRECTORY"))); + ASSERT_OK(free_and_strdup(&saved_secret, getenv("SYSTEMD_CREDENTIAL_SECRET"))); + ASSERT_OK(mkdtemp_malloc(NULL, &plain_dir)); + ASSERT_OK(mkdtemp_malloc(NULL, &encrypted_dir)); + ASSERT_OK(mkdtemp_malloc(NULL, &secret_dir)); + ASSERT_NOT_NULL(secret_path = path_join(secret_dir, "secret")); + ASSERT_OK_ERRNO(setenv("CREDENTIALS_DIRECTORY", plain_dir, /* overwrite= */ true)); + ASSERT_OK_ERRNO(setenv("ENCRYPTED_CREDENTIALS_DIRECTORY", encrypted_dir, /* overwrite= */ true)); + ASSERT_OK_ERRNO(setenv("SYSTEMD_CREDENTIAL_SECRET", secret_path, /* overwrite= */ true)); + + ASSERT_OK_ZERO(read_credential_with_decryption("missing", &missing, &missing_size)); + ASSERT_NULL(missing); + ASSERT_EQ(missing_size, 0U); + + ASSERT_NOT_NULL(encrypted_path = path_join(encrypted_dir, "foo")); + ASSERT_OK(write_string_file(encrypted_path, "!", WRITE_STRING_FILE_CREATE)); + { + _cleanup_(iovec_done_erase) struct iovec result = {}; + + ASSERT_ERROR(read_credential_with_decryption("foo", &result.iov_base, &result.iov_len), + EINVAL); + } + + /* Reject oversized decoded data even when the Base64 encoding is well-formed. */ + { + _cleanup_free_ void *oversized = NULL; + + ASSERT_NOT_NULL(oversized = malloc0(CREDENTIAL_ENCRYPTED_SIZE_MAX + 1)); + FOREACH_ELEMENT(line_break, ((const size_t[]) { SIZE_MAX, 79 })) { + _cleanup_free_ char *encoded = NULL; + _cleanup_(iovec_done_erase) struct iovec result = {}; + + ASSERT_OK(base64mem_full(oversized, CREDENTIAL_ENCRYPTED_SIZE_MAX + 1, + *line_break, &encoded)); + ASSERT_OK(write_string_file(encrypted_path, encoded, WRITE_STRING_FILE_TRUNCATE)); + ASSERT_ERROR(read_credential_with_decryption("foo", &result.iov_base, + &result.iov_len), E2BIG); + } + } + + /* Plaintext takes precedence over the invalid encrypted credential and is not Base64-decoded. */ + ASSERT_NOT_NULL(plain_path = path_join(plain_dir, "foo")); + ASSERT_OK(write_string_file(plain_path, "Zm9v", + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_AVOID_NEWLINE)); + { + _cleanup_(iovec_done_erase) struct iovec result = {}; + + ASSERT_OK_EQ(read_credential_with_decryption("foo", &result.iov_base, &result.iov_len), 1); + ASSERT_EQ(result.iov_len, STRLEN("Zm9v")); + ASSERT_STREQ(result.iov_base, "Zm9v"); + } + { + _cleanup_(erase_and_freep) char *text = NULL; + + ASSERT_OK_EQ(read_credential_with_decryption("foo", (void**) &text, + /* ret_size= */ NULL), 1); + ASSERT_STREQ(text, "Zm9v"); + } + + /* Asking for a string must reject embedded NUL bytes, without falling back to the encrypted file. */ + ASSERT_OK(write_data_file_atomic_at(AT_FDCWD, plain_path, &binary, /* flags= */ 0)); + { + _cleanup_(iovec_done_erase) struct iovec result = {}; + + ASSERT_OK_EQ(read_credential_with_decryption("foo", &result.iov_base, &result.iov_len), 1); + ASSERT_TRUE(iovec_equal(&binary, &result)); + } + { + _cleanup_(erase_and_freep) char *text = NULL; + + ASSERT_ERROR(read_credential_with_decryption("foo", (void**) &text, /* ret_size= */ NULL), + EBADMSG); + ASSERT_NULL(text); + } + + ASSERT_OK(set_unset_env("CREDENTIALS_DIRECTORY", saved_plain, /* overwrite= */ true)); + ASSERT_OK(set_unset_env("ENCRYPTED_CREDENTIALS_DIRECTORY", saved_encrypted, /* overwrite= */ true)); + ASSERT_OK(set_unset_env("SYSTEMD_CREDENTIAL_SECRET", saved_secret, /* overwrite= */ true)); +} + +TEST(read_credential_with_decryption_encrypted) { + static const struct iovec plaintext = CONST_IOVEC_MAKE_STRING("foo\0bar\n"); + _cleanup_(rm_rf_physical_and_freep) char *plain_dir = NULL, *encrypted_dir = NULL; + _cleanup_(rm_rf_physical_and_freep) char *secret_dir = NULL; + _cleanup_free_ char *saved_plain = NULL, *saved_encrypted = NULL, *saved_secret = NULL; + _cleanup_free_ char *secret_path = NULL, *credential_path = NULL; + _cleanup_(iovec_done_erase) struct iovec large_plaintext = {}; + int r; + + if (geteuid() != 0) + return (void) log_tests_skipped("reading encrypted credentials directly requires root"); + + ASSERT_OK(free_and_strdup(&saved_plain, getenv("CREDENTIALS_DIRECTORY"))); + ASSERT_OK(free_and_strdup(&saved_encrypted, getenv("ENCRYPTED_CREDENTIALS_DIRECTORY"))); + ASSERT_OK(free_and_strdup(&saved_secret, getenv("SYSTEMD_CREDENTIAL_SECRET"))); + ASSERT_OK(mkdtemp_malloc(NULL, &plain_dir)); + ASSERT_OK(mkdtemp_malloc(NULL, &encrypted_dir)); + ASSERT_OK(mkdtemp_malloc(NULL, &secret_dir)); + ASSERT_NOT_NULL(secret_path = path_join(secret_dir, "secret")); + ASSERT_NOT_NULL(credential_path = path_join(encrypted_dir, "foo")); + ASSERT_OK_ERRNO(setenv("CREDENTIALS_DIRECTORY", plain_dir, /* overwrite= */ true)); + ASSERT_OK_ERRNO(setenv("ENCRYPTED_CREDENTIALS_DIRECTORY", encrypted_dir, /* overwrite= */ true)); + ASSERT_OK_ERRNO(setenv("SYSTEMD_CREDENTIAL_SECRET", secret_path, /* overwrite= */ true)); + + ASSERT_NOT_NULL(large_plaintext.iov_base = malloc0(CREDENTIAL_SIZE_MAX)); + large_plaintext.iov_len = CREDENTIAL_SIZE_MAX; + + const struct { + struct iovec data; + bool contains_nul; + } inputs[] = { + { .data = CONST_IOVEC_MAKE_STRING("Zm9v"), .contains_nul = false }, + { .data = CONST_IOVEC_MAKE_STRING(""), .contains_nul = false }, + { .data = plaintext, .contains_nul = true }, + { .data = large_plaintext, .contains_nul = true }, + }; + + FOREACH_ELEMENT(input, inputs) { + _cleanup_(iovec_done_erase) struct iovec encrypted = {}; + + r = encrypt_credential_and_warn( + CRED_AES256_GCM_BY_HOST, + "foo", + /* timestamp= */ USEC_INFINITY, + /* not_after= */ USEC_INFINITY, + /* tpm2_device= */ NULL, + /* tpm2_hash_pcr_mask= */ 0, + /* tpm2_pubkey_path= */ NULL, + /* tpm2_pubkey_pcr_mask= */ 0, + /* uid= */ UID_INVALID, + &input->data, + /* flags= */ 0, + &encrypted); + if (ERRNO_IS_NEG_NOT_SUPPORTED(r)) { + (void) log_tests_skipped("encrypted credentials are not supported"); + goto finish; + } + if (ERRNO_IS_NEG_MACHINE_ID_UNSET(r)) { + (void) log_tests_skipped("machine ID is not initialized"); + goto finish; + } + ASSERT_OK(r); + + /* Exercise both single-line Base64 and the line wrapping used by systemd-creds. */ + FOREACH_ELEMENT(line_break, ((const size_t[]) { SIZE_MAX, 79 })) { + _cleanup_(erase_and_freep) char *encoded = NULL; + _cleanup_(iovec_done_erase) struct iovec result = {}; + _cleanup_(erase_and_freep) char *text = NULL; + + ASSERT_OK(base64mem_full(encrypted.iov_base, encrypted.iov_len, + *line_break, &encoded)); + if (input->data.iov_len == CREDENTIAL_SIZE_MAX) + ASSERT_GT(strlen(encoded), (size_t) CREDENTIAL_ENCRYPTED_SIZE_MAX); + ASSERT_OK(write_string_file(credential_path, encoded, + WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_TRUNCATE)); + ASSERT_OK_EQ(read_credential_with_decryption("foo", &result.iov_base, + &result.iov_len), 1); + ASSERT_TRUE(iovec_equal(&input->data, &result)); + + r = read_credential_with_decryption("foo", (void**) &text, /* ret_size= */ NULL); + if (input->contains_nul) { + ASSERT_ERROR(r, EBADMSG); + ASSERT_NULL(text); + } else { + ASSERT_OK_EQ(r, 1); + ASSERT_STREQ(text, input->data.iov_base); + } + } + } + + /* At and just below the size limit, valid Base64 still reaches the credential-format parser. */ + { + _cleanup_free_ void *invalid = NULL; + + ASSERT_NOT_NULL(invalid = malloc0(CREDENTIAL_ENCRYPTED_SIZE_MAX)); + FOREACH_ELEMENT(delta, ((const size_t[]) { 1, 0 })) { + _cleanup_free_ char *encoded = NULL; + _cleanup_(iovec_done_erase) struct iovec result = {}; + + ASSERT_OK(base64mem_full(invalid, CREDENTIAL_ENCRYPTED_SIZE_MAX - *delta, + 79, &encoded)); + ASSERT_OK(write_string_file(credential_path, encoded, WRITE_STRING_FILE_TRUNCATE)); + ASSERT_ERROR(read_credential_with_decryption("foo", &result.iov_base, + &result.iov_len), EOPNOTSUPP); + } + } + +finish: + ASSERT_OK(set_unset_env("CREDENTIALS_DIRECTORY", saved_plain, /* overwrite= */ true)); + ASSERT_OK(set_unset_env("ENCRYPTED_CREDENTIALS_DIRECTORY", saved_encrypted, /* overwrite= */ true)); + ASSERT_OK(set_unset_env("SYSTEMD_CREDENTIAL_SECRET", saved_secret, /* overwrite= */ true)); +} + TEST(credential_name_valid) { char buf[NAME_MAX+2];