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
This commit is contained in:
Totoro
2026-09-10 00:20:09 +09:00
committed by Yu Watanabe
parent 6a68191199
commit 951cfae10b
2 changed files with 225 additions and 8 deletions
+18 -8
View File
@@ -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 */
+207
View File
@@ -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];