Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/stellar-core_example.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ KNOWN_PEERS=[
# This example also adds a common name to NODE_NAMES list named `self` with the
# public key associated to this seed
NODE_SEED="SBI3CZU7XZEWVXU7OZLW5MMUQAP334JFOPXSLTPOH43IRTEQ2QYXU5RG self"
#
# You can also load the seed from a file (must have permissions 0600 on Unix):
# NODE_SEED="$FILE:/etc/stellar/node_seed"
# The file should contain the full seed string, optionally followed by a
# space and a common name (e.g., "SBI3CZU7... self").

# NODE_IS_VALIDATOR (boolean) default false.
# Only nodes that want to participate in SCP should set NODE_IS_VALIDATOR=true.
Expand Down
15 changes: 14 additions & 1 deletion src/main/Config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include "util/Fs.h"
#include "util/GlobalChecks.h"
#include "util/Logging.h"
#include "util/SecretManager.h"
#include "util/UnorderedSet.h"

#include <fmt/chrono.h>
Expand Down Expand Up @@ -1079,6 +1080,7 @@ Config::processConfig(std::shared_ptr<cpptoml::table> t)
}
std::vector<ValidatorEntry> validators;
UnorderedMap<std::string, ValidatorQuality> domainQualityMap;
bool usedExternalSecrets = false;

// cpptoml returns the items in non-deterministic order
// so we need to process items that are potential dependencies first
Expand Down Expand Up @@ -1323,7 +1325,11 @@ Config::processConfig(std::shared_ptr<cpptoml::table> t)
{"NODE_SEED",
[&]() {
PublicKey nodeID;
parseNodeID(readString(item), nodeID, NODE_SEED, true);
auto raw = readString(item);
usedExternalSecrets = usedExternalSecrets ||
secretmanager::isExternalSecret(raw);
parseNodeID(secretmanager::resolve(raw), nodeID, NODE_SEED,
true);
}},
{"NODE_IS_VALIDATOR",
[&]() { NODE_IS_VALIDATOR = readBool(item); }},
Expand Down Expand Up @@ -2077,6 +2083,13 @@ Config::processConfig(std::shared_ptr<cpptoml::table> t)
gIsProductionNetwork = NETWORK_PASSPHRASE ==
"Public Global Stellar Network ; September 2015";

if (gIsProductionNetwork && usedExternalSecrets)
{
throw std::invalid_argument(
"External secret references ($FILE:) are not supported on "
"the public network");
}

// Validators default to starting the network from local state
FORCE_SCP = NODE_IS_VALIDATOR;

Expand Down
151 changes: 151 additions & 0 deletions src/main/test/ConfigTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
#include "test/Catch2.h"
#include "test/test.h"
#include "util/Math.h"
#include "util/SecretManager.h"
#include <filesystem>
#include <fmt/format.h>
#include <fstream>

using namespace stellar;
namespace stdfs = std::filesystem;

namespace
{
Expand Down Expand Up @@ -700,3 +704,150 @@ PUBLIC_KEY="GBVZFVEARURUJTN5ABZPKW36FHKVJK2GHXEVY2SZCCNU5I3CQMTZ3OES"
std::stringstream ss(configStr);
c.load(ss);
}

TEST_CASE("secret resolution", "[config]")
{
// A known test seed and its expected public key
std::string const testSeed =
"SA7FGJMMUIHNE3ZPI2UO5I632A7O5FBAZTXFAIEVFA4DSSGLHXACLAIT";
auto expectedKey = SecretKey::fromStrKeySeed(testSeed).getPublicKey();

SECTION("resolve passthrough for plain values")
{
REQUIRE(secretmanager::resolve("hello") == "hello");
REQUIRE(secretmanager::resolve(testSeed) == testSeed);
REQUIRE(secretmanager::resolve("sqlite3://test.db") ==
"sqlite3://test.db");
}

SECTION("resolve from file with correct permissions")
{
std::string tmpPath = "/tmp/stellar_test_seed_file";
{
std::ofstream ofs(tmpPath);
ofs << testSeed;
}
stdfs::permissions(tmpPath, stdfs::perms::owner_read |
stdfs::perms::owner_write);
auto resolved = secretmanager::resolve("$FILE:" + tmpPath);
REQUIRE(resolved == testSeed);
std::remove(tmpPath.c_str());
}

SECTION("resolve from file trims trailing whitespace")
{
std::string tmpPath = "/tmp/stellar_test_seed_trim";
{
std::ofstream ofs(tmpPath);
ofs << testSeed << "\n";
}
stdfs::permissions(tmpPath, stdfs::perms::owner_read |
stdfs::perms::owner_write);
auto resolved = secretmanager::resolve("$FILE:" + tmpPath);
REQUIRE(resolved == testSeed);
std::remove(tmpPath.c_str());
}

SECTION("reject missing file")
{
REQUIRE_THROWS_WITH(
secretmanager::resolve("$FILE:/tmp/stellar_nonexistent_file"),
Catch::Contains("not a regular file"));
}

SECTION("reject file with overly permissive permissions")
{
std::string tmpPath = "/tmp/stellar_test_seed_perm";
{
std::ofstream ofs(tmpPath);
ofs << testSeed;
}
stdfs::permissions(
tmpPath, stdfs::perms::owner_read | stdfs::perms::owner_write |
stdfs::perms::group_read | stdfs::perms::others_read);
REQUIRE_THROWS_WITH(secretmanager::resolve("$FILE:" + tmpPath),
Catch::Contains("permissive permissions"));
std::remove(tmpPath.c_str());
}

SECTION("reject empty file")
{
std::string tmpPath = "/tmp/stellar_test_seed_empty";
{
std::ofstream ofs(tmpPath);
// write nothing
}
stdfs::permissions(tmpPath, stdfs::perms::owner_read |
stdfs::perms::owner_write);
REQUIRE_THROWS_WITH(secretmanager::resolve("$FILE:" + tmpPath),
Catch::Contains("empty"));
std::remove(tmpPath.c_str());
}

SECTION("NODE_SEED from file in config")
{
std::string tmpPath = "/tmp/stellar_test_node_seed";
{
std::ofstream ofs(tmpPath);
ofs << testSeed << " self\n";
}
stdfs::permissions(tmpPath, stdfs::perms::owner_read |
stdfs::perms::owner_write);
auto otherKey = SecretKey::random().getStrKeyPublic();
std::string configStr = R"(
NODE_SEED="$FILE:)" + tmpPath +
R"("
UNSAFE_QUORUM=true
[QUORUM_SET]
THRESHOLD_PERCENT=100
VALIDATORS=[")" + otherKey + R"( A"]
)";
Config c;
std::stringstream ss(configStr);
c.load(ss);
REQUIRE(c.NODE_SEED.getPublicKey() == expectedKey);
std::remove(tmpPath.c_str());
}

SECTION("backward compatibility - inline NODE_SEED")
{
auto otherKey = SecretKey::random().getStrKeyPublic();
std::string configStr = R"(
NODE_SEED=")" + testSeed + R"( self"
UNSAFE_QUORUM=true
[QUORUM_SET]
THRESHOLD_PERCENT=100
VALIDATORS=[")" + otherKey + R"( A"]
)";
Config c;
std::stringstream ss(configStr);
c.load(ss);
REQUIRE(c.NODE_SEED.getPublicKey() == expectedKey);
}

SECTION("reject external secrets on public network")
{
std::string tmpPath = "/tmp/stellar_test_node_seed_pubnet";
{
std::ofstream ofs(tmpPath);
ofs << testSeed << " self";
}
stdfs::permissions(tmpPath, stdfs::perms::owner_read |
stdfs::perms::owner_write);
auto otherKey = SecretKey::random().getStrKeyPublic();
std::string configStr = R"(
NODE_SEED="$FILE:)" + tmpPath +
R"("
NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015"
UNSAFE_QUORUM=true
[QUORUM_SET]
THRESHOLD_PERCENT=100
VALIDATORS=[")" + otherKey + R"( A"]
)";
Config c;
std::stringstream ss(configStr);
REQUIRE_THROWS_WITH(c.load(ss),
Catch::Contains("not supported on the public"));
std::remove(tmpPath.c_str());
}
}
92 changes: 92 additions & 0 deletions src/util/SecretManager.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 Stellar Development Foundation and contributors. Licensed
// under the Apache License, Version 2.0. See the COPYING file at the root
// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0

#include "util/SecretManager.h"
#include "util/Logging.h"

#include <filesystem>
#include <fstream>
#include <sstream>
#include <stdexcept>

namespace stellar
{
namespace secretmanager
{

namespace stdfs = std::filesystem;

static std::string const FILE_PREFIX = "$FILE:";

static std::string
rtrim(std::string s)
{
auto end = s.find_last_not_of(" \t\n\r");
if (end == std::string::npos)
{
return "";
}
return s.substr(0, end + 1);
}

static void
checkFilePermissions(std::string const& filePath)
{
stdfs::path p(filePath);
auto status = stdfs::status(p);
if (!stdfs::is_regular_file(status))
{
throw std::runtime_error("Secret path is not a regular file: " +
filePath);
}
auto perms = status.permissions();
// Reject if group or others have any access
auto forbidden = stdfs::perms::group_all | stdfs::perms::others_all;
if ((perms & forbidden) != stdfs::perms::none)
{
throw std::runtime_error(
"Secret file has overly permissive permissions "
"(must not be accessible by group or others): " +
filePath);
}
}

static std::string
resolveFromFile(std::string const& filePath)
{
LOG_INFO(DEFAULT_LOG, "Resolving secret from file");
checkFilePermissions(filePath);
std::ifstream ifs(filePath);
if (!ifs.is_open())
{
throw std::runtime_error("Cannot open secret file: " + filePath);
}
std::stringstream ss;
ss << ifs.rdbuf();
std::string result = rtrim(ss.str());
if (result.empty())
{
throw std::runtime_error("Secret file is empty: " + filePath);
}
return result;
}

std::string
resolve(std::string const& configValue)
{
if (configValue.substr(0, FILE_PREFIX.size()) == FILE_PREFIX)
{
return resolveFromFile(configValue.substr(FILE_PREFIX.size()));
}
return configValue;
}

bool
isExternalSecret(std::string const& configValue)
{
return configValue.substr(0, FILE_PREFIX.size()) == FILE_PREFIX;
}

} // namespace secretmanager
} // namespace stellar
29 changes: 29 additions & 0 deletions src/util/SecretManager.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2026 Stellar Development Foundation and contributors. Licensed
// under the Apache License, Version 2.0. See the COPYING file at the root
// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0

#pragma once

#include <string>

namespace stellar
{
namespace secretmanager
{

// Resolve a config value that may reference an external secret source.
//
// Supported prefixes:
// "$FILE:/path/file" - read from file (must have permissions 0600 or
// stricter)
// no prefix - return the value unchanged (backward compatible)
//
// Throws std::runtime_error on failure (unreadable file, bad permissions,
// empty resolved value).
std::string resolve(std::string const& configValue);

// Returns true if the value uses an external secret reference ($FILE: prefix).
bool isExternalSecret(std::string const& configValue);

} // namespace secretmanager
} // namespace stellar
Loading