From 368352dfa4f7a81125b12f29461e66ee740a1575 Mon Sep 17 00:00:00 2001
From: Damien Diederen
Date: Wed, 17 May 2023 11:11:40 +0200
Subject: [PATCH 1/2] http-binary-cache-store: Add 'ssl-cert' and 'ssl-key'
settings
Those are set via the store's URI, e.g.:
https://substituter.invalid?ssl-cert=/path/to/cert.pem&ssl-key=/path/to/key.pem
---
doc/manual/rl-next/mtls-substituter.md | 13 ++++++++++++
src/libstore/filetransfer.cc | 6 ++++++
src/libstore/http-binary-cache-store.cc | 21 +++++++++++++++++--
.../include/nix/store/filetransfer.hh | 2 ++
.../nix/store/http-binary-cache-store.hh | 6 ++++++
5 files changed, 46 insertions(+), 2 deletions(-)
create mode 100644 doc/manual/rl-next/mtls-substituter.md
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 8080fcfdd..ed1f4cbb5 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 2b591dda9..43519cd00 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 10c3ec7ef..1567e055f 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";
From d7450465bb1fc9ca5e5f56b6889beb3465f3bc15 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=B6rg=20Thalheim?=
Date: Mon, 26 May 2025 12:29:09 +0200
Subject: [PATCH 2/2] http-binary-cache-store: add tests for tls substitution
---
tests/functional/meson.build | 1 +
.../functional/nix-binary-cache-ssl-server.py | 103 ++++++++++++++++++
.../functional/substituter-ssl-client-cert.sh | 100 +++++++++++++++++
3 files changed, 204 insertions(+)
create mode 100644 tests/functional/nix-binary-cache-ssl-server.py
create mode 100644 tests/functional/substituter-ssl-client-cert.sh
diff --git a/tests/functional/meson.build b/tests/functional/meson.build
index b2005d9d9..4ac6334de 100644
--- a/tests/functional/meson.build
+++ b/tests/functional/meson.build
@@ -105,6 +105,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