diff --git a/doc/manual/rl-next/mtls-substituter.md b/doc/manual/rl-next/mtls-substituter.md new file mode 100644 index 000000000..21f5401e4 --- /dev/null +++ b/doc/manual/rl-next/mtls-substituter.md @@ -0,0 +1,13 @@ +--- +synopsis: Support substituters using mTLS (client certificate) authentication +issues: [] +prs: [13030] +--- + +Added support for `ssl-cert` and `ssl-key` options in substituter URLs. + +Example: + + https://substituter.invalid?ssl-cert=/path/to/cert.pem&ssl-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/filetransfer.cc b/src/libstore/filetransfer.cc index 7e29d00e6..1592e801e 100644 --- a/src/libstore/filetransfer.cc +++ b/src/libstore/filetransfer.cc @@ -410,6 +410,12 @@ struct curlFileTransfer : public FileTransfer if (writtenToSink) curl_easy_setopt(req, CURLOPT_RESUME_FROM_LARGE, writtenToSink); + if (!request.sslCert.empty()) + curl_easy_setopt(req, CURLOPT_SSLCERT, request.sslCert.c_str()); + + if (!request.sslKey.empty()) + curl_easy_setopt(req, CURLOPT_SSLKEY, request.sslKey.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 e44d146b9..2a298ade4 100644 --- a/src/libstore/http-binary-cache-store.cc +++ b/src/libstore/http-binary-cache-store.cc @@ -152,11 +152,28 @@ protected: FileTransferRequest makeRequest(const std::string & path) { - return FileTransferRequest( - hasPrefix(path, "https://") || hasPrefix(path, "http://") || hasPrefix(path, "file://") + bool absolute = hasPrefix(path, "https://") || hasPrefix(path, "http://") || hasPrefix(path, "file://"); + + FileTransferRequest request( + absolute ? path : config->cacheUri + "/" + path); + if (!absolute) { + Path sslCert = config->sslCert.get(); + if (!sslCert.empty()) { + debug("configuring SSL client certificate '%s' for '%s'", sslCert, request.uri); + request.sslCert = sslCert; + } + + Path sslKey = config->sslKey.get(); + if (!sslKey.empty()) { + debug("configuring SSL client certificate key '%s' for '%s'", sslKey, request.uri); + request.sslKey = sslKey; + } + } + + return request; } void getFile(const std::string & path, Sink & sink) override diff --git a/src/libstore/include/nix/store/filetransfer.hh b/src/libstore/include/nix/store/filetransfer.hh index 745aeb29e..9ab3500bb 100644 --- a/src/libstore/include/nix/store/filetransfer.hh +++ b/src/libstore/include/nix/store/filetransfer.hh @@ -65,6 +65,8 @@ struct FileTransferRequest std::string uri; Headers headers; std::string expectedETag; + Path sslCert; + Path sslKey; bool verifyTLS = true; bool head = false; bool post = false; 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 66ec5f8d2..fe2bde144 100644 --- a/src/libstore/include/nix/store/http-binary-cache-store.hh +++ b/src/libstore/include/nix/store/http-binary-cache-store.hh @@ -13,6 +13,12 @@ struct HttpBinaryCacheStoreConfig : std::enable_shared_from_this sslCert{ + this, "", "ssl-cert", "An optional SSL client certificate in PEM format; see CURLOPT_SSLCERT."}; + + const Setting sslKey{ + this, "", "ssl-key", "The SSL client certificate key in PEM format; see CURLOPT_SSLKEY."}; + static const std::string name() { return "HTTP Binary Cache Store"; diff --git a/tests/functional/meson.build b/tests/functional/meson.build index cd1bc6319..69b91c890 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -106,6 +106,7 @@ suites = [ 'build-remote-trustless-should-pass-3.sh', 'build-remote-trustless-should-fail-0.sh', 'build-remote-with-mounted-ssh-ng.sh', + 'substituter-ssl-client-cert.sh', 'nar-access.sh', 'impure-eval.sh', 'pure-eval.sh', diff --git a/tests/functional/nix-binary-cache-ssl-server.py b/tests/functional/nix-binary-cache-ssl-server.py new file mode 100644 index 000000000..cb5e24390 --- /dev/null +++ b/tests/functional/nix-binary-cache-ssl-server.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import http.server +import ssl +import socketserver +import sys +import os +import argparse +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 = 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: int, 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) + + # Create and start server + httpd: socketserver.TCPServer = socketserver.TCPServer(('localhost', port), NixCacheHandler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + + 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', type=int, default=8443, help='Port to listen on') + 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, args.cert, args.key, args.ca_cert) diff --git a/tests/functional/substituter-ssl-client-cert.sh b/tests/functional/substituter-ssl-client-cert.sh new file mode 100644 index 000000000..8dff9f3e6 --- /dev/null +++ b/tests/functional/substituter-ssl-client-cert.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# shellcheck source=common.sh +source common.sh + +# 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 + +# Find a free port +PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()') + +# Start the SSL cache server +python3 "${_NIX_TEST_SOURCE_DIR}/nix-binary-cache-ssl-server.py" \ + --port "$PORT" \ + --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 +} +trap stopServer EXIT + +tries=0 +while ! curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \ + "https://localhost:$PORT/nix-cache-info"; do + if (( tries++ >= 50 )); then + if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Server started but did not respond in time" >&2 + else + echo "Server failed to start" >&2 + fi + exit 1 + fi + sleep 0.1 +done + +# 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://localhost:$PORT?ssl-cert=$TEST_ROOT/client.crt&ssl-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 info --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://localhost:$PORT?ssl-cert=$TEST_ROOT/wrong.crt&ssl-key=$TEST_ROOT/wrong.key" + +rm -rf "$TEST_HOME" + +# This should fail +if nix store info --download-attempts 0 --store "$wrongCache"; then + echo "FAIL: Should have rejected wrong certificate" >&2 + exit 1 +fi