1
0
Fork 0
mirror of https://github.com/NixOS/nix synced 2025-06-24 13:51:16 +02:00
This commit is contained in:
László Vaskó 2025-06-23 10:36:00 +00:00 committed by GitHub
commit dcb070e678
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 250 additions and 2 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -13,6 +13,12 @@ struct HttpBinaryCacheStoreConfig : std::enable_shared_from_this<HttpBinaryCache
Path cacheUri;
const Setting<std::string> sslCert{
this, "", "ssl-cert", "An optional SSL client certificate in PEM format; see CURLOPT_SSLCERT."};
const Setting<std::string> 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";

View file

@ -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',

View file

@ -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)

View file

@ -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