diff --git a/docs/stellar-core_example.cfg b/docs/stellar-core_example.cfg index 4b89d9931..172e952cd 100644 --- a/docs/stellar-core_example.cfg +++ b/docs/stellar-core_example.cfg @@ -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. diff --git a/src/main/Config.cpp b/src/main/Config.cpp index 8e6c8975d..3db749a3b 100644 --- a/src/main/Config.cpp +++ b/src/main/Config.cpp @@ -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 @@ -1079,6 +1080,7 @@ Config::processConfig(std::shared_ptr t) } std::vector validators; UnorderedMap domainQualityMap; + bool usedExternalSecrets = false; // cpptoml returns the items in non-deterministic order // so we need to process items that are potential dependencies first @@ -1323,7 +1325,11 @@ Config::processConfig(std::shared_ptr 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); }}, @@ -2077,6 +2083,13 @@ Config::processConfig(std::shared_ptr 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; diff --git a/src/main/test/ConfigTests.cpp b/src/main/test/ConfigTests.cpp index 1c540e01a..db47c68fd 100644 --- a/src/main/test/ConfigTests.cpp +++ b/src/main/test/ConfigTests.cpp @@ -10,9 +10,13 @@ #include "test/Catch2.h" #include "test/test.h" #include "util/Math.h" +#include "util/SecretManager.h" +#include #include +#include using namespace stellar; +namespace stdfs = std::filesystem; namespace { @@ -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()); + } +} diff --git a/src/util/SecretManager.cpp b/src/util/SecretManager.cpp new file mode 100644 index 000000000..3da90091d --- /dev/null +++ b/src/util/SecretManager.cpp @@ -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 +#include +#include +#include + +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 diff --git a/src/util/SecretManager.h b/src/util/SecretManager.h new file mode 100644 index 000000000..4eb48d61c --- /dev/null +++ b/src/util/SecretManager.h @@ -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 + +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