mirror of
https://github.com/NixOS/nix
synced 2025-06-24 13:51:16 +02:00
Merge d7450465bb
into 6a74590063
This commit is contained in:
commit
dcb070e678
8 changed files with 250 additions and 2 deletions
13
doc/manual/rl-next/mtls-substituter.md
Normal file
13
doc/manual/rl-next/mtls-substituter.md
Normal 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.
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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',
|
||||
|
|
103
tests/functional/nix-binary-cache-ssl-server.py
Normal file
103
tests/functional/nix-binary-cache-ssl-server.py
Normal 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)
|
100
tests/functional/substituter-ssl-client-cert.sh
Normal file
100
tests/functional/substituter-ssl-client-cert.sh
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue