diff --git a/contrib/plugins/meson.build b/contrib/plugins/meson.build new file mode 100644 index 000000000..c40e6175a --- /dev/null +++ b/contrib/plugins/meson.build @@ -0,0 +1,14 @@ +plugin_mtls_store = shared_module( + 'plugin_mtls_store', + 'plugin_mtls_store.cc', + dependencies : [ + liblixutil, + liblixstore, + liblixexpr, + liblixfetchers, + curl, + ], + install : false, + build_by_default : true, + link_args : strict_shared_module_link_args, +) diff --git a/contrib/plugins/mtls-http-binary-cache-store.md b/contrib/plugins/mtls-http-binary-cache-store.md new file mode 100644 index 000000000..1274f7b61 --- /dev/null +++ b/contrib/plugins/mtls-http-binary-cache-store.md @@ -0,0 +1,13 @@ +R"( + +**Store URL format**: `https+mtls://...` + +This store allows a binary cache to be accessed via the HTTPS +protocol with mutual TLS mandated. + +Two parameters can be passed to the query string: + +- `tls-certificate`, a path to the TLS client certificate (optional) +- `tls-private-key`, a path to the TLS private key backing the client certificate (required) + +)" diff --git a/contrib/plugins/plugin_mtls_store.cc b/contrib/plugins/plugin_mtls_store.cc new file mode 100644 index 000000000..759301323 --- /dev/null +++ b/contrib/plugins/plugin_mtls_store.cc @@ -0,0 +1,102 @@ +#include "lix/libstore/store-api.hh" +#include "lix/libutil/config.hh" +#include "lix/libstore/http-binary-cache-store.hh" +#include +#include + +namespace nix { +struct mTLSBinaryCacheStoreConfig : HttpBinaryCacheStoreConfig +{ + using HttpBinaryCacheStoreConfig::HttpBinaryCacheStoreConfig; + + const std::string name() override + { + return "mTLS HTTP Binary Cache Store"; + } + + std::string doc() override + { + return +#include "mtls-http-binary-cache-store.md" + ; + } + + PathsSetting tlsCertificate{ + this, + "", + "tls-certificate", + "Path of an optional TLS client certificate in PEM format as expected by CURLOPT_SSLCERT" + }; + + PathsSetting tlsKey{ + this, + "", + "tls-private-key", + "Path of an TLS client certificate private key in PEM format as expected by CURLOPT_SSLKEY" + }; +}; + +struct mTLSBinaryCacheStoreImpl : public HttpBinaryCacheStore +{ + struct Keyring + { + nix::Path tlsCertificate; + nix::Path tlsKey; + }; + + mTLSBinaryCacheStoreConfig config_; + std::shared_ptr keyring; + + mTLSBinaryCacheStoreConfig & config() override + { + return config_; + } + const mTLSBinaryCacheStoreConfig & config() const override + { + return config_; + } + + mTLSBinaryCacheStoreImpl( + const std::string & uriScheme, const Path & _cacheUri, mTLSBinaryCacheStoreConfig config + ) + : Store(config) + , HttpBinaryCacheStore("https", _cacheUri, config) + , config_(std::move(config)) + , keyring(std::make_shared(config_.tlsCertificate.get(), config_.tlsKey.get())) + { + } + + FileTransferOptions makeOptions(Headers && headers = {}) override + { + auto options = HttpBinaryCacheStore::makeOptions(std::move(headers)); + auto baseExtraSetup = std::move(options.extraSetup); + auto keyring = this->keyring; + + options.extraSetup = + [keyring, baseExtraSetup{std::move(baseExtraSetup)}](CURL * req) { + if (baseExtraSetup) { + baseExtraSetup(req); + } + + if (!keyring->tlsCertificate.empty()) { + curl_easy_setopt(req, CURLOPT_SSLCERT, keyring->tlsCertificate.c_str()); + } + + curl_easy_setopt(req, CURLOPT_SSLKEY, keyring->tlsKey.c_str()); + }; + + return options; + } + + static std::set uriSchemes() + { + return {"https+mtls"}; + } +}; +} + + +extern "C" void nix_plugin_entry() +{ + nix::StoreImplementations::add(); +} diff --git a/doc/manual/change-authors.yml b/doc/manual/change-authors.yml index 313e0647c..d544d843a 100644 --- a/doc/manual/change-authors.yml +++ b/doc/manual/change-authors.yml @@ -258,6 +258,9 @@ vigress8: forgejo: vigress8 github: vigress8 +vlaci: + github: vlaci + vlinkz: display_name: Victor Fuentes forgejo: vlinkz diff --git a/doc/manual/rl-next/mtls-plugin.md b/doc/manual/rl-next/mtls-plugin.md new file mode 100644 index 000000000..1dc284535 --- /dev/null +++ b/doc/manual/rl-next/mtls-plugin.md @@ -0,0 +1,42 @@ +--- +synopsis: "mTLS store connections via a plugin" +issues: [] +cls: [3754, 3696, 3697, 3698] +category: Improvements +credits: [raito, horrors, mic92, vlaci] +--- + +To support use cases requiring mutual TLS (mTLS) authentication when connecting +to remote Nix stores, e.g. private stores, we have introduced a **contributed** +mTLS plugin extending the Lix store interface. + +This design follows an extensibility model which was brought up [by a proposal +of making Kerberos authentication possible in Lix +directly](https://gerrit.lix.systems/c/lix/+/3637). + +This mTLS plugin serves as a concrete example of how store connection +mechanisms can be modularized through external plugins, without extending Lix +core. This idea can be generalized to integrate automatic certificate renewal +or advanced integrations with secrets engine or posture checks. + +It enables custom TLS client certificates to be used for authenticating against +a remote store that enforces mTLS. + +To use the plugin, configure Lix manually by setting in your `nix.conf`: + +``` +plugin-files = /a/path/to/libplugin_mtls_store.so +``` + +Currently, this must be done explicitly. In the future, Nixpkgs will provide a +mechanism to reference an up-to-date and curated set of plugins automatically. + +Making plugins easily consumable outside of Nixpkgs (e.g., from external plugin +registries or binary distributions) remains an open question and will require +further design. + +Contributed plugins come with significantly reduced **stability** and +**maintenance** guarantees compared to the Lix core. We encourage users who +depend on a given plugin to take on maintenance responsibilities and apply for +ownership within the Lix mono-repository. These plugins are subject to removal +at any time. diff --git a/meson.build b/meson.build index bede78a64..1ff1ae99b 100644 --- a/meson.build +++ b/meson.build @@ -112,6 +112,7 @@ endif enable_nix_eval_jobs = get_option('nix-eval-jobs') enable_tests = get_option('enable-tests') +enable_contrib_plugins = get_option('enable-contrib-plugins') tests_args = [] @@ -229,12 +230,13 @@ is_x64 = host_machine.cpu_family() == 'x86_64' # This corresponds to the $(1)_ALLOW_UNDEFINED option from the Make buildsystem. # Mostly this is load-bearing on the plugin tests defined in tests/functional/plugins/meson.build. shared_module_link_args = [] +# This is a stricter additional set of link flags. +strict_shared_module_link_args = [] if is_darwin shared_module_link_args += ['-undefined', 'suppress', '-flat_namespace'] + strict_shared_module_link_args += ['-flat_namespace'] elif is_linux - # -Wl,-z,defs is the equivalent, but a comment in the Make buildsystem says that breaks - # Clang sanitizers on Linux. - # FIXME(Qyriad): is that true? + strict_shared_module_link_args += ['-Wl,-z,defs'] endif configdata = { } @@ -694,6 +696,10 @@ if enable_tests subdir('tests/functional2') endif +if enable_contrib_plugins + subdir('contrib/plugins') +endif + subdir('meson/clang-tidy') subproject('nix-eval-jobs', required : enable_nix_eval_jobs) diff --git a/meson.options b/meson.options index ce1177e63..926eaea30 100644 --- a/meson.options +++ b/meson.options @@ -36,6 +36,10 @@ option('enable-tests', type : 'boolean', value : true, description : 'whether to enable tests or not (requires rapidcheck and gtest)', ) +option('enable-contrib-plugins', type : 'boolean', value : true, + description : 'whether to build contributed plugins' +) + option('tests-color', type : 'boolean', value : true, description : 'set to false to disable color output in gtest', ) diff --git a/misc/pre-commit.nix b/misc/pre-commit.nix index 51be9cabf..44c35067c 100644 --- a/misc/pre-commit.nix +++ b/misc/pre-commit.nix @@ -36,7 +36,7 @@ pre-commit-run { enable = true; package = pkgs.llvmPackages.libclang.python; entry = "${pkgs.llvmPackages.libclang.python}/bin/git-clang-format --binary ${pkgs.llvmPackages.clang-tools}/bin/clang-format"; - files = "^(lix/|tests/)"; + files = "^(lix/|tests/|contrib/plugins/)"; types = [ "c++" "file" diff --git a/package.nix b/package.nix index fb86ce844..9c861d8dd 100644 --- a/package.nix +++ b/package.nix @@ -252,6 +252,7 @@ stdenv.mkDerivation (finalAttrs: { ./doc ./lix ./misc + ./contrib/plugins ./COPYING ] ++ lib.optionals lintInsteadOfBuild [ ./.clang-tidy ] @@ -333,6 +334,10 @@ stdenv.mkDerivation (finalAttrs: { yq lsof zstd + # For mTLS tests. + curl + openssl + python3 ] ++ lib.optional hostPlatform.isLinux util-linuxMinimal ++ lib.optional (!officialRelease && buildUnreleasedNotes) build-release-notes diff --git a/tests/functional/init.sh b/tests/functional/init.sh index 33ae8b2bd..aa0bac187 100755 --- a/tests/functional/init.sh +++ b/tests/functional/init.sh @@ -10,7 +10,7 @@ if test -d "$TEST_ROOT"; then killDaemon rm -rf "$TEST_ROOT" fi -mkdir "$TEST_ROOT" +mkdir -p "$TEST_ROOT" mkdir "$NIX_STORE_DIR" mkdir "$NIX_LOCALSTATE_DIR" diff --git a/tests/functional/meson.build b/tests/functional/meson.build index c514bd9e5..3e4cd3bde 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -173,6 +173,12 @@ if get_option('default_library') != 'static' functional_tests_scripts += ['plugins.sh'] endif +if get_option('default_library') != 'static' and get_option('enable-contrib-plugins') + functional_tests_scripts += [ + 'plugins/mtls/substituter-ssl-client-cert.sh' + ] +endif + # TODO(Qyriad): this will hopefully be able to be removed when we remove the autoconf+Make # buildsystem. See the comments at the top of setup-functional-tests.py for why this is here. meson.add_install_script( diff --git a/tests/functional/plugins/mtls/nix-binary-cache-ssl-server.py b/tests/functional/plugins/mtls/nix-binary-cache-ssl-server.py new file mode 100755 index 000000000..5bf4cbbba --- /dev/null +++ b/tests/functional/plugins/mtls/nix-binary-cache-ssl-server.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +import http.server +import ssl +import socketserver +import sys +import os +import argparse +import textwrap +from typing import Any + +class NixCacheHandler(http.server.BaseHTTPRequestHandler): + protocol_version: str = 'HTTP/1.1' + + def do_GET(self) -> None: + # Get client certificate information + try: + client_cert: dict[str, Any] | None = self.request.getpeercert() + except Exception as e: + print(f"Error getting client certificate: {e}", file=sys.stderr) + self.send_error(403, "Invalid client certificate") + return + + if not client_cert: + self.send_error(403, "No client certificate provided") + return + + # Additional validation - check if certificate chain is valid + subject: tuple[tuple[tuple[str, str], ...], ...] | None = client_cert.get('subject') + if not subject: + self.send_error(403, "Invalid client certificate: No subject") + return + + # Log client info + print(f"Client connected: {subject}", file=sys.stderr) + print(f"Path requested: {self.path}", file=sys.stderr) + + # Handle nix-cache-info endpoint + if self.path == '/nix-cache-info': + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.send_header('Connection', 'close') # Explicitly close after response + test_root: str | None = os.environ.get('TEST_ROOT') + if not test_root: + store_root: str = '/nix/store' + else: + store_root = os.path.join(test_root, 'store') + + # Nix cache info format + cache_info: str = textwrap.dedent(f"""\ + StoreDir: {store_root} + WantMassQuery: 1 + Priority: 30 + """) + self.send_header('Content-Length', str(len(cache_info))) + self.end_headers() + self.wfile.write(cache_info.encode()) + self.wfile.flush() # Ensure data is sent + + # Handle .narinfo requests + elif self.path.endswith('.narinfo'): + # Return 404 for all narinfo requests (empty cache) + self.send_response(404) + self.send_header('Content-Length', '0') + self.send_header('Connection', 'close') + self.end_headers() + + else: + self.send_response(404) + self.send_header('Content-Length', '0') + self.send_header('Connection', 'close') + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + # Suppress standard logging + pass + +def run_server(port_fifo_path: str, certfile: str, keyfile: str, ca_certfile: str) -> None: + # Create SSL context + context: ssl.SSLContext = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile=certfile, keyfile=keyfile) + context.verify_mode = ssl.VerifyMode.CERT_REQUIRED + context.check_hostname = False # We're not checking hostnames for client certs + context.load_verify_locations(cafile=ca_certfile) + + # Bind to a free port + with socketserver.TCPServer(("localhost", 0), NixCacheHandler) as httpd: + port = httpd.server_address[1] # Extract chosen port + + # Wrap with TLS + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + + # Write the port to the FIFO + with open(port_fifo_path, 'w') as fifo: + fifo.write(f"{port}\n") + fifo.flush() + + print(f"Server running on port {port}", file=sys.stderr) + + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() + +if __name__ == "__main__": + parser: argparse.ArgumentParser = argparse.ArgumentParser(description='Nix binary cache server with SSL client verification') + parser.add_argument('--port-fifo', type=str, required=True, help="FIFO where to inform about the port taken") + parser.add_argument('--cert', required=True, help='Server certificate file') + parser.add_argument('--key', required=True, help='Server private key file') + parser.add_argument('--ca-cert', required=True, help='CA certificate for client verification') + + args: argparse.Namespace = parser.parse_args() + + run_server(args.port_fifo, args.cert, args.key, args.ca_cert) diff --git a/tests/functional/plugins/mtls/substituter-ssl-client-cert.sh b/tests/functional/plugins/mtls/substituter-ssl-client-cert.sh new file mode 100755 index 000000000..17ab873ee --- /dev/null +++ b/tests/functional/plugins/mtls/substituter-ssl-client-cert.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +# shellcheck source=common.sh +source ../../common.sh + +# Load the mTLS plugin for these tests. +loadContribPlugin "mtls_store" + +# Generate test certificates using EC keys for faster generation + +# Generate CA with EC key +openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/ca.key" 2>/dev/null +openssl req -new -x509 -days 1 -key "$TEST_ROOT/ca.key" -out "$TEST_ROOT/ca.crt" \ + -subj "/C=US/ST=Test/L=Test/O=TestCA/CN=Test CA" 2>/dev/null + +# Generate server certificate with EC key +openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/server.key" 2>/dev/null +openssl req -new -key "$TEST_ROOT/server.key" -out "$TEST_ROOT/server.csr" \ + -subj "/C=US/ST=Test/L=Test/O=TestServer/CN=localhost" 2>/dev/null +openssl x509 -req -days 1 -in "$TEST_ROOT/server.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \ + -set_serial 01 -out "$TEST_ROOT/server.crt" 2>/dev/null + +# Generate client certificate with EC key +openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/client.key" 2>/dev/null +openssl req -new -key "$TEST_ROOT/client.key" -out "$TEST_ROOT/client.csr" \ + -subj "/C=US/ST=Test/L=Test/O=TestClient/CN=Nix Test Client" 2>/dev/null +openssl x509 -req -days 1 -in "$TEST_ROOT/client.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \ + -set_serial 02 -out "$TEST_ROOT/client.crt" 2>/dev/null + +# Start the server and have it write its chosen port to the FIFO +FIFO_PATH="$TEST_ROOT/server-port.fifo" +mkfifo "$FIFO_PATH" +python3 "$PWD/nix-binary-cache-ssl-server.py" \ + --port-fifo "$FIFO_PATH" \ + --cert "$TEST_ROOT/server.crt" \ + --key "$TEST_ROOT/server.key" \ + --ca-cert "$TEST_ROOT/ca.crt" & +SERVER_PID=$! + +# Function to stop server on exit +stopServer() { + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + rm -f "$FIFO_PATH" +} +trap stopServer EXIT + +# Read port from the FIFO (waits until server writes to it) but timeouts after 5s. +if ! PORT=$(timeout 5s bash -c "read -r line < '$FIFO_PATH'; echo \"\$line\""); then + echo "Timed out waiting for server to write port to FIFO" >&2 + exit 1 +fi + +if ! curl -sSf -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \ + "https://localhost:$PORT/nix-cache-info" > /dev/null; then + if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Server started but did not respond to curl" >&2 + else + echo "Server failed to start" >&2 + fi + exit 1 +fi + +# Test 1: Verify server rejects connections without client certificate +echo "Testing connection without client certificate (should fail)..." >&2 +if curl -s -k "https://localhost:$PORT/nix-cache-info" 2>&1 | grep -q "certificate required"; then + echo "FAIL: Server should have rejected connection" >&2 + exit 1 +fi + +# Test 2: Verify server accepts connections with client certificate +echo "Testing connection with client certificate..." >&2 +RESPONSE=$(curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \ + "https://localhost:$PORT/nix-cache-info") + +if ! echo "$RESPONSE" | grepQuiet "StoreDir: "; then + echo "FAIL: Server should have accepted client certificate: $RESPONSE" >&2 + exit 1 +fi + +# Test 3: Test Nix with SSL client certificate parameters +# Set up substituter URL with SSL parameters +sslCache="https+mtls://localhost:$PORT?tls-certificate=$TEST_ROOT/client.crt&tls-private-key=$TEST_ROOT/client.key" + +# Configure Nix to trust our CA +export NIX_SSL_CERT_FILE="$TEST_ROOT/ca.crt" + +# Test nix store info +nix store ping --store "$sslCache" --json # | jq -e '.url' | grepQuiet "https://localhost:$PORT" + +# Test 4: Verify incorrect client certificate is rejected +# Generate a different client cert not signed by our CA (also using EC) +openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/wrong.key" 2>/dev/null +openssl req -new -x509 -days 1 -key "$TEST_ROOT/wrong.key" -out "$TEST_ROOT/wrong.crt" \ + -subj "/C=US/ST=Test/L=Test/O=Wrong/CN=Wrong Client" 2>/dev/null + +wrongCache="https+mtls://localhost:$PORT?tls-certificate=$TEST_ROOT/wrong.crt&tls-private-key=$TEST_ROOT/wrong.key" + +rm -rf "$TEST_HOME" + +# This should fail +if nix store ping --download-attempts 0 --store "$wrongCache"; then + echo "FAIL: Should have rejected wrong certificate" >&2 + exit 1 +fi