diff --git a/doc/manual/rl-next/mtls-substituter.md b/doc/manual/rl-next/mtls-substituter.md new file mode 100644 index 00000000000..a27c80e9fd1 --- /dev/null +++ b/doc/manual/rl-next/mtls-substituter.md @@ -0,0 +1,15 @@ +--- +synopsis: Support HTTPS binary caches using mTLS (client certificate) authentication +issues: [13002] +prs: [13030] +--- + +Added support for `tls-certificate` and `tls-private-key` options in substituter URLs. + +Example: + +``` +https://substituter.invalid?tls-certificate=/path/to/cert.pem&tls-private-key=/path/to/key.pem +``` + +When these options are configured, Nix will use this certificate/private key pair to authenticate to the server. diff --git a/src/libstore-test-support/https-store.cc b/src/libstore-test-support/https-store.cc new file mode 100644 index 00000000000..bcf5ec17711 --- /dev/null +++ b/src/libstore-test-support/https-store.cc @@ -0,0 +1,125 @@ +#include "nix/store/tests/https-store.hh" + +#include + +namespace nix::testing { + +void TestHttpBinaryCacheStore::init() +{ + BinaryCacheStore::init(); +} + +ref TestHttpBinaryCacheStoreConfig::openTestStore() const +{ + auto store = make_ref( + ref{// FIXME we shouldn't actually need a mutable config + std::const_pointer_cast(shared_from_this())}); + store->init(); + return store; +} + +void HttpsBinaryCacheStoreTest::openssl(Strings args) +{ + runProgram("openssl", /*lookupPath=*/true, args); +} + +void HttpsBinaryCacheStoreTest::SetUp() +{ + LibStoreNetworkTest::SetUp(); + +#ifdef _WIN32 + GTEST_SKIP() << "HTTPS store tests are not supported on Windows"; +#endif + + tmpDir = createTempDir(); + cacheDir = tmpDir / "cache"; + delTmpDir = std::make_unique(tmpDir); + + localCacheStore = + make_ref("file", cacheDir.string(), LocalBinaryCacheStoreConfig::Params{}) + ->openStore(); + + caCert = tmpDir / "ca.crt"; + caKey = tmpDir / "ca.key"; + serverCert = tmpDir / "server.crt"; + serverKey = tmpDir / "server.key"; + clientCert = tmpDir / "client.crt"; + clientKey = tmpDir / "client.key"; + + // clang-format off + openssl({"ecparam", "-genkey", "-name", "prime256v1", "-out", caKey.string()}); + openssl({"req", "-new", "-x509", "-days", "1", "-key", caKey.string(), "-out", caCert.string(), "-subj", "/CN=TestCA"}); + openssl({"ecparam", "-genkey", "-name", "prime256v1", "-out", serverKey.string()}); + openssl({"req", "-new", "-key", serverKey.string(), "-out", (tmpDir / "server.csr").string(), "-subj", "/CN=localhost"}); + openssl({"x509", "-req", "-in", (tmpDir / "server.csr").string(), "-CA", caCert.string(), "-CAkey", caKey.string(), "-CAcreateserial", "-out", serverCert.string(), "-days", "1"}); + openssl({"ecparam", "-genkey", "-name", "prime256v1", "-out", clientKey.string()}); + openssl({"req", "-new", "-key", clientKey.string(), "-out", (tmpDir / "client.csr").string(), "-subj", "/CN=TestClient"}); + openssl({"x509", "-req", "-in", (tmpDir / "client.csr").string(), "-CA", caCert.string(), "-CAkey", caKey.string(), "-CAcreateserial", "-out", clientCert.string(), "-days", "1"}); + // clang-format on + +#ifndef _WIN32 /* FIXME: Can't yet start processes on windows */ + auto args = serverArgs(); + serverPid = startProcess( + [&] { + if (chdir(cacheDir.c_str()) == -1) + _exit(1); + std::vector argv; + argv.push_back(const_cast("openssl")); + for (auto & a : args) + argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + execvp("openssl", argv.data()); + _exit(1); + }, + {.dieWithParent = true}); +#endif + + /* As an optimization, sleep for a bit to allow the server to come up to avoid retrying when connecting. + This won't make the tests fail, but does make them run faster. We don't need to overcomplicate by waiting + for the port explicitly - this is enough. */ + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + /* FIXME: Don't use global settings. Tests are not run concurrently, so this is fine for now. */ + oldCaCert = settings.caFile; + settings.caFile = caCert.string(); +} + +void HttpsBinaryCacheStoreTest::TearDown() +{ + settings.caFile = oldCaCert; + serverPid.kill(); + delTmpDir.reset(); +} + +std::vector HttpsBinaryCacheStoreTest::serverArgs() +{ + return { + "s_server", + "-accept", + std::to_string(port), + "-cert", + serverCert.string(), + "-key", + serverKey.string(), + "-WWW", /* Serve from current directory. */ + "-quiet", + }; +} + +std::vector HttpsBinaryCacheStoreMtlsTest::serverArgs() +{ + auto args = HttpsBinaryCacheStoreTest::serverArgs(); + /* With the -Verify option the client must supply a certificate or an error occurs, which is not the + case with -verify. */ + args.insert(args.end(), {"-CAfile", caCert.string(), "-Verify", "1", "-verify_return_error"}); + return args; +} + +ref HttpsBinaryCacheStoreTest::makeConfig(BinaryCacheStoreConfig::Params params) +{ + auto res = make_ref("https", fmt("localhost:%d", port), std::move(params)); + res->pathInfoCacheSize = 0; /* We don't want any caching in tests. */ + return res; +} + +} // namespace nix::testing diff --git a/src/libstore-test-support/include/nix/store/tests/https-store.hh b/src/libstore-test-support/include/nix/store/tests/https-store.hh new file mode 100644 index 00000000000..17b38e7d51c --- /dev/null +++ b/src/libstore-test-support/include/nix/store/tests/https-store.hh @@ -0,0 +1,93 @@ +#pragma once +///@file + +#include +#include + +#include "nix/store/tests/libstore-network.hh" +#include "nix/store/http-binary-cache-store.hh" +#include "nix/store/store-api.hh" +#include "nix/store/globals.hh" +#include "nix/store/local-binary-cache-store.hh" +#include "nix/util/file-system.hh" +#include "nix/util/processes.hh" + +namespace nix::testing { + +class TestHttpBinaryCacheStoreConfig; + +/** + * Test shim for testing. We don't want to use the on-disk narinfo cache in unit + * tests. + */ +class TestHttpBinaryCacheStore : public HttpBinaryCacheStore +{ +public: + TestHttpBinaryCacheStore(const TestHttpBinaryCacheStore &) = delete; + TestHttpBinaryCacheStore(TestHttpBinaryCacheStore &&) = delete; + TestHttpBinaryCacheStore & operator=(const TestHttpBinaryCacheStore &) = delete; + TestHttpBinaryCacheStore & operator=(TestHttpBinaryCacheStore &&) = delete; + + TestHttpBinaryCacheStore(ref config) + : Store{*config} + , BinaryCacheStore{*config} + , HttpBinaryCacheStore(config) + { + diskCache = nullptr; /* Disable caching, we'll be creating a new binary cache for each test. */ + } + + void init() override; +}; + +class TestHttpBinaryCacheStoreConfig : public HttpBinaryCacheStoreConfig +{ +public: + TestHttpBinaryCacheStoreConfig( + std::string_view scheme, std::string_view cacheUri, const Store::Config::Params & params) + : StoreConfig(params) + , HttpBinaryCacheStoreConfig(scheme, cacheUri, params) + { + } + + ref openTestStore() const; + + ref openStore() const override + { + return openTestStore(); + } +}; + +class HttpsBinaryCacheStoreTest : public virtual LibStoreNetworkTest +{ + std::unique_ptr delTmpDir; + +public: + static void SetUpTestSuite() + { + initLibStore(/*loadConfig=*/false); + } + +protected: + std::filesystem::path tmpDir, cacheDir; + std::filesystem::path caCert, caKey, serverCert, serverKey; + std::filesystem::path clientCert, clientKey; + std::string oldCaCert; + Pid serverPid; + uint16_t port = 8443; + std::shared_ptr localCacheStore; + + static void openssl(Strings args); + void SetUp() override; + void TearDown() override; + + virtual std::vector serverArgs(); + ref makeConfig(BinaryCacheStoreConfig::Params params); +}; + +class HttpsBinaryCacheStoreMtlsTest : public HttpsBinaryCacheStoreTest +{ +protected: + std::vector serverArgs() override; +}; + +} // namespace nix::testing diff --git a/src/libstore-test-support/include/nix/store/tests/libstore-network.hh b/src/libstore-test-support/include/nix/store/tests/libstore-network.hh new file mode 100644 index 00000000000..ab03ace5618 --- /dev/null +++ b/src/libstore-test-support/include/nix/store/tests/libstore-network.hh @@ -0,0 +1,39 @@ +#pragma once +/// @file + +#include + +namespace nix::testing { + +/** + * Whether to run network tests. This is global so that the test harness can + * enable this by default if we can run tests in isolation. + */ +extern bool networkTestsAvailable; + +/** + * Set up network tests and, if on linux, create a new network namespace for + * tests with a loopback interface. This is to avoid binding to ports in the + * host's namespace. + */ +void setupNetworkTests(); + +class LibStoreNetworkTest : public virtual ::testing::Test +{ +protected: + void SetUp() override + { + if (networkTestsAvailable) + return; + static bool warned = false; + if (!warned) { + warned = true; + GTEST_SKIP() + << "Network tests not enabled by default without user namespaces, use NIX_TEST_FORCE_NETWORK_TESTS=1 to override"; + } else { + GTEST_SKIP(); + } + } +}; + +} // namespace nix::testing diff --git a/src/libstore-test-support/include/nix/store/tests/meson.build b/src/libstore-test-support/include/nix/store/tests/meson.build index 33524de3851..8d844d24e7d 100644 --- a/src/libstore-test-support/include/nix/store/tests/meson.build +++ b/src/libstore-test-support/include/nix/store/tests/meson.build @@ -4,6 +4,8 @@ include_dirs = [ include_directories('../../..') ] headers = files( 'derived-path.hh', + 'https-store.hh', + 'libstore-network.hh', 'libstore.hh', 'nix_api_store.hh', 'outputs-spec.hh', diff --git a/src/libstore-test-support/libstore-network.cc b/src/libstore-test-support/libstore-network.cc new file mode 100644 index 00000000000..8aa047bdd60 --- /dev/null +++ b/src/libstore-test-support/libstore-network.cc @@ -0,0 +1,60 @@ +#include "nix/store/tests/libstore-network.hh" +#include "nix/util/error.hh" +#include "nix/util/environment-variables.hh" + +#ifdef __linux__ +# include "nix/util/file-system.hh" +# include "nix/util/linux-namespaces.hh" +# include +# include +# include +# include +#endif + +namespace nix::testing { + +bool networkTestsAvailable = false; + +#ifdef __linux__ + +static void enterNetworkNamespace() +{ + auto uid = ::getuid(); + auto gid = ::getgid(); + + if (::unshare(CLONE_NEWUSER | CLONE_NEWNET) == -1) + throw SysError("setting up a private network namespace for tests"); + + std::filesystem::path procSelf = "/proc/self"; + writeFile(procSelf / "setgroups", "deny"); + writeFile(procSelf / "uid_map", fmt("%d %d 1", uid, uid)); + writeFile(procSelf / "gid_map", fmt("%d %d 1", gid, gid)); + + AutoCloseFD fd(::socket(PF_INET, SOCK_DGRAM, IPPROTO_IP)); + if (!fd) + throw SysError("cannot open IP socket for loopback interface"); + + struct ::ifreq ifr = {}; + strcpy(ifr.ifr_name, "lo"); + ifr.ifr_flags = IFF_UP | IFF_LOOPBACK | IFF_RUNNING; + if (::ioctl(fd.get(), SIOCSIFFLAGS, &ifr) == -1) + throw SysError("cannot set loopback interface flags"); +} + +#endif + +void setupNetworkTests() +try { + networkTestsAvailable = getEnvOs(OS_STR("NIX_TEST_FORCE_NETWORK_TESTS")).has_value(); + +#ifdef __linux__ + if (!networkTestsAvailable && userNamespacesSupported()) { + enterNetworkNamespace(); + networkTestsAvailable = true; + } +#endif +} catch (SystemError & e) { + /* Ignore any set up errors. */ +} + +} // namespace nix::testing diff --git a/src/libstore-test-support/meson.build b/src/libstore-test-support/meson.build index 8617225d743..4d904cb1d06 100644 --- a/src/libstore-test-support/meson.build +++ b/src/libstore-test-support/meson.build @@ -28,10 +28,15 @@ subdir('nix-meson-build-support/subprojects') rapidcheck = dependency('rapidcheck') deps_public += rapidcheck +gtest = dependency('gtest') +deps_public += gtest + subdir('nix-meson-build-support/common') sources = files( 'derived-path.cc', + 'https-store.cc', + 'libstore-network.cc', 'outputs-spec.cc', 'path.cc', 'test-main.cc', diff --git a/src/libstore-test-support/package.nix b/src/libstore-test-support/package.nix index 391ddeefda2..4f20f2cbe71 100644 --- a/src/libstore-test-support/package.nix +++ b/src/libstore-test-support/package.nix @@ -7,6 +7,7 @@ nix-store-c, rapidcheck, + gtest, # Configuration Options @@ -39,6 +40,7 @@ mkMesonLibrary (finalAttrs: { nix-store nix-store-c rapidcheck + gtest ]; mesonFlags = [ diff --git a/src/libstore-tests/http-binary-cache-store.cc b/src/libstore-tests/http-binary-cache-store.cc index 4b3754a1fe4..bee23216f57 100644 --- a/src/libstore-tests/http-binary-cache-store.cc +++ b/src/libstore-tests/http-binary-cache-store.cc @@ -1,6 +1,9 @@ #include +#include #include "nix/store/http-binary-cache-store.hh" +#include "nix/store/tests/https-store.hh" +#include "nix/util/fs-sink.hh" namespace nix { @@ -34,4 +37,88 @@ TEST(HttpBinaryCacheStore, constructConfigWithParamsAndUrlWithParams) EXPECT_EQ(config.getReference().params, params); } +using testing::HttpsBinaryCacheStoreMtlsTest; +using testing::HttpsBinaryCacheStoreTest; + +using namespace std::string_view_literals; +using namespace std::string_literals; + +TEST_F(HttpsBinaryCacheStoreTest, queryPathInfo) +{ + auto config = makeConfig({}); + auto store = config->openStore(); + StringSource dump{"test"sv}; + auto path = localCacheStore->addToStoreFromDump(dump, "test-name", FileSerialisationMethod::Flat); + EXPECT_NO_THROW(store->queryPathInfo(path)); +} + +auto withNoRetries() +{ + auto oldTries = fileTransferSettings.tries.get(); + Finally restoreTries = [=]() { fileTransferSettings.tries = oldTries; }; + fileTransferSettings.tries = 1; /* FIXME: Don't use global settings. */ + return restoreTries; +} + +TEST_F(HttpsBinaryCacheStoreMtlsTest, queryPathInfo) +{ + auto config = makeConfig({ + {"tls-certificate"s, clientCert.string()}, + {"tls-private-key"s, clientKey.string()}, + }); + auto store = config->openStore(); + StringSource dump{"test"sv}; + auto path = localCacheStore->addToStoreFromDump(dump, "test-name", FileSerialisationMethod::Flat); + EXPECT_NO_THROW(store->queryPathInfo(path)); +} + +TEST_F(HttpsBinaryCacheStoreMtlsTest, rejectsWithoutClientCert) +{ + auto restoreTries = withNoRetries(); + auto config = makeConfig({}); + EXPECT_THROW(config->openStore(), Error); +} + +TEST_F(HttpsBinaryCacheStoreMtlsTest, rejectsWrongClientCert) +{ + auto wrongKey = tmpDir / "wrong.key"; + auto wrongCert = tmpDir / "wrong.crt"; + + // clang-format off + openssl({"ecparam", "-genkey", "-name", "prime256v1", "-out", wrongKey.string()}); + openssl({"req", "-new", "-x509", "-days", "1", "-key", wrongKey.string(), "-out", wrongCert.string(), "-subj", "/CN=WrongClient"}); + // clang-format on + + auto config = makeConfig({ + {"tls-certificate"s, wrongCert.string()}, + {"tls-private-key"s, wrongKey.string()}, + }); + auto restoreTries = withNoRetries(); + EXPECT_THROW(config->openStore(), Error); +} + +TEST_F(HttpsBinaryCacheStoreMtlsTest, doesNotSendCertOnRedirectToDifferentAuthority) +{ + StringSource dump{"test"sv}; + auto path = localCacheStore->addToStoreFromDump(dump, "test-name", FileSerialisationMethod::Flat); + + for (auto & entry : DirectoryIterator{cacheDir}) + if (entry.path().extension() == ".narinfo") { + auto content = readFile(entry.path()); + content = std::regex_replace(content, std::regex("URL: nar/"), fmt("URL: https://127.0.0.1:%d/nar/", port)); + writeFile(entry.path(), content); + } + + auto config = makeConfig({ + {"tls-certificate"s, clientCert.string()}, + {"tls-private-key"s, clientKey.string()}, + }); + auto store = config->openStore(); + + auto restoreTries = withNoRetries(); + auto info = store->queryPathInfo(path); + NullSink null; + EXPECT_THROW(store->narFromPath(path, null), Error); +} + } // namespace nix diff --git a/src/libstore-tests/main.cc b/src/libstore-tests/main.cc index ffe9816134f..c45e3a7f384 100644 --- a/src/libstore-tests/main.cc +++ b/src/libstore-tests/main.cc @@ -1,6 +1,7 @@ #include #include "nix/store/tests/test-main.hh" +#include "nix/store/tests/libstore-network.hh" using namespace nix; @@ -10,6 +11,7 @@ int main(int argc, char ** argv) if (res) return res; + nix::testing::setupNetworkTests(); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/src/libstore-tests/package.nix b/src/libstore-tests/package.nix index ac547aca35e..2bd0ab1a5db 100644 --- a/src/libstore-tests/package.nix +++ b/src/libstore-tests/package.nix @@ -9,6 +9,7 @@ nix-store-c, nix-store-test-support, sqlite, + openssl, rapidcheck, gtest, @@ -75,7 +76,10 @@ mkMesonExecutable (finalAttrs: { runCommand "${finalAttrs.pname}-run" { meta.broken = !stdenv.hostPlatform.emulatorAvailable buildPackages; - buildInputs = [ writableTmpDirAsHomeHook ]; + nativeBuildInputs = [ + writableTmpDirAsHomeHook + openssl + ]; } ( '' diff --git a/src/libstore/filetransfer.cc b/src/libstore/filetransfer.cc index 72cdd5146ec..e32a0d62afe 100644 --- a/src/libstore/filetransfer.cc +++ b/src/libstore/filetransfer.cc @@ -531,6 +531,21 @@ struct curlFileTransfer : public FileTransfer if (writtenToSink) curl_easy_setopt(req, CURLOPT_RESUME_FROM_LARGE, writtenToSink); + /* Note that the underlying strings get copied by libcurl, so the path -> string conversion is ok: + > The application does not have to keep the string around after setting this option. + https://curl.se/libcurl/c/CURLOPT_SSLKEY.html + https://curl.se/libcurl/c/CURLOPT_SSLCERT.html */ + + if (request.tlsCert) { + curl_easy_setopt(req, CURLOPT_SSLCERTTYPE, "PEM"); + curl_easy_setopt(req, CURLOPT_SSLCERT, request.tlsCert->string().c_str()); + } + + if (request.tlsKey) { + curl_easy_setopt(req, CURLOPT_SSLKEYTYPE, "PEM"); + curl_easy_setopt(req, CURLOPT_SSLKEY, request.tlsKey->string().c_str()); + } + curl_easy_setopt(req, CURLOPT_ERRORBUFFER, errbuf); errbuf[0] = 0; diff --git a/src/libstore/http-binary-cache-store.cc b/src/libstore/http-binary-cache-store.cc index 92a551de880..943f1ed94d6 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -195,7 +195,23 @@ FileTransferRequest HttpBinaryCacheStore::makeRequest(std::string_view path) result.query = config->cacheUri.query; } - return FileTransferRequest(result); + FileTransferRequest request(result); + + /* Only use the specified SSL certificate and private key if the resolved URL names the same + authority and uses the same protocol. */ + if (result.scheme == config->cacheUri.scheme && result.authority == config->cacheUri.authority) { + if (const auto & cert = config->tlsCert.get()) { + debug("using TLS client certificate %s for '%s'", PathFmt(*cert), request.uri); + request.tlsCert = *cert; + } + + if (const auto & key = config->tlsKey.get()) { + debug("using TLS client key '%s' for '%s'", PathFmt(*key), request.uri); + request.tlsKey = *key; + } + } + + return request; } void HttpBinaryCacheStore::getFile(const std::string & path, Sink & sink) diff --git a/src/libstore/include/nix/store/filetransfer.hh b/src/libstore/include/nix/store/filetransfer.hh index 57b781c3320..500d9ebd7b4 100644 --- a/src/libstore/include/nix/store/filetransfer.hh +++ b/src/libstore/include/nix/store/filetransfer.hh @@ -121,6 +121,16 @@ struct FileTransferRequest ActivityId parentAct; bool decompress = true; + /** + * Optional path to the client certificate in "PEM" format. Only used for TLS-based protocols. + */ + std::optional tlsCert; + + /** + * Optional path to the client private key in "PEM" format. Only used for TLS-based protocols. + */ + std::optional tlsKey; + struct UploadData { UploadData(StringSource & s) diff --git a/src/libstore/include/nix/store/http-binary-cache-store.hh b/src/libstore/include/nix/store/http-binary-cache-store.hh index 12a21b27b08..9047233acf4 100644 --- a/src/libstore/include/nix/store/http-binary-cache-store.hh +++ b/src/libstore/include/nix/store/http-binary-cache-store.hh @@ -37,6 +37,12 @@ struct HttpBinaryCacheStoreConfig : std::enable_shared_from_this> tlsCert{ + this, std::nullopt, "tls-certificate", "Path to an optional TLS client certificate in PEM format."}; + + const Setting> tlsKey{ + this, std::nullopt, "tls-private-key", "Path to an optional TLS client certificate private key in PEM format."}; + static const std::string name() { return "HTTP Binary Cache Store";