Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/manual/rl-next/mtls-substituter.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions src/libstore/filetransfer.cc
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,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;

Expand Down
23 changes: 19 additions & 4 deletions src/libstore/http-binary-cache-store.cc
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,25 @@ class HttpBinaryCacheStore : public virtual BinaryCacheStore

FileTransferRequest makeRequest(const std::string & path)
{
return FileTransferRequest(
hasPrefix(path, "https://") || hasPrefix(path, "http://") || hasPrefix(path, "file://")
? path
: config->cacheUri + "/" + path);
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
Expand Down
2 changes: 2 additions & 0 deletions src/libstore/include/nix/store/filetransfer.hh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ struct FileTransferRequest
std::string uri;
Headers headers;
std::string expectedETag;
Path sslCert;
Path sslKey;
bool verifyTLS = true;
bool head = false;
bool post = false;
Expand Down
6 changes: 6 additions & 0 deletions src/libstore/include/nix/store/http-binary-cache-store.hh
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions tests/functional/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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',
Expand Down
103 changes: 103 additions & 0 deletions tests/functional/nix-binary-cache-ssl-server.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions tests/functional/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
jq,
git,
mercurial,

curl,
openssl,
python3,

util-linux,
unixtools,

Expand Down Expand Up @@ -57,6 +62,11 @@ mkMesonDerivation (
git
mercurial
unixtools.script

# for store tests
curl
openssl
python3
]
++ lib.optionals stdenv.hostPlatform.isLinux [
# For various sandboxing tests that needs a statically-linked shell,
Expand Down
106 changes: 106 additions & 0 deletions tests/functional/substituter-ssl-client-cert.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash

# shellcheck source=common.sh
source common.sh

# These are not installed in vm_tests
[[ $(type -p curl) ]] || skipTest "curl is not installed"
[[ $(type -p openssl) ]] || skipTest "openssl is not installed"
[[ $(type -p python3) ]] || skipTest "python3 is not installed"

# 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()') \
|| skipTest "Cannot bind to a TCP port"

# 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