Skip to content

Commit 2647fa2

Browse files
Mic92vlaci
authored andcommitted
http-binary-cache-store: add tests for tls substitution
1 parent 2918223 commit 2647fa2

File tree

4 files changed

+219
-0
lines changed

4 files changed

+219
-0
lines changed

tests/functional/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ suites = [
106106
'build-remote-trustless-should-pass-3.sh',
107107
'build-remote-trustless-should-fail-0.sh',
108108
'build-remote-with-mounted-ssh-ng.sh',
109+
'substituter-ssl-client-cert.sh',
109110
'nar-access.sh',
110111
'impure-eval.sh',
111112
'pure-eval.sh',
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
import http.server
3+
import ssl
4+
import socketserver
5+
import sys
6+
import os
7+
import argparse
8+
from typing import Any
9+
10+
class NixCacheHandler(http.server.BaseHTTPRequestHandler):
11+
protocol_version: str = 'HTTP/1.1'
12+
13+
def do_GET(self) -> None:
14+
# Get client certificate information
15+
try:
16+
client_cert: dict[str, Any] | None = self.request.getpeercert()
17+
except Exception as e:
18+
print(f"Error getting client certificate: {e}", file=sys.stderr)
19+
self.send_error(403, "Invalid client certificate")
20+
return
21+
22+
if not client_cert:
23+
self.send_error(403, "No client certificate provided")
24+
return
25+
26+
# Additional validation - check if certificate chain is valid
27+
subject: tuple[tuple[tuple[str, str], ...], ...] | None = client_cert.get('subject')
28+
if not subject:
29+
self.send_error(403, "Invalid client certificate: No subject")
30+
return
31+
32+
# Log client info
33+
print(f"Client connected: {subject}", file=sys.stderr)
34+
print(f"Path requested: {self.path}", file=sys.stderr)
35+
36+
# Handle nix-cache-info endpoint
37+
if self.path == '/nix-cache-info':
38+
self.send_response(200)
39+
self.send_header('Content-Type', 'text/plain')
40+
self.send_header('Connection', 'close') # Explicitly close after response
41+
test_root: str | None = os.environ.get('TEST_ROOT')
42+
if not test_root:
43+
store_root: str = '/nix/store'
44+
else:
45+
store_root = os.path.join(test_root, 'store')
46+
47+
# Nix cache info format
48+
cache_info: str = f"""StoreDir: {store_root}
49+
WantMassQuery: 1
50+
Priority: 30
51+
"""
52+
self.send_header('Content-Length', str(len(cache_info)))
53+
self.end_headers()
54+
self.wfile.write(cache_info.encode())
55+
self.wfile.flush() # Ensure data is sent
56+
57+
# Handle .narinfo requests
58+
elif self.path.endswith('.narinfo'):
59+
# Return 404 for all narinfo requests (empty cache)
60+
self.send_response(404)
61+
self.send_header('Content-Length', '0')
62+
self.send_header('Connection', 'close')
63+
self.end_headers()
64+
65+
else:
66+
self.send_response(404)
67+
self.send_header('Content-Length', '0')
68+
self.send_header('Connection', 'close')
69+
self.end_headers()
70+
71+
def log_message(self, format: str, *args: Any) -> None:
72+
# Suppress standard logging
73+
pass
74+
75+
def run_server(port: int, certfile: str, keyfile: str, ca_certfile: str) -> None:
76+
# Create SSL context
77+
context: ssl.SSLContext = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
78+
context.load_cert_chain(certfile=certfile, keyfile=keyfile)
79+
context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
80+
context.check_hostname = False # We're not checking hostnames for client certs
81+
context.load_verify_locations(cafile=ca_certfile)
82+
83+
# Create and start server
84+
httpd: socketserver.TCPServer = socketserver.TCPServer(('localhost', port), NixCacheHandler)
85+
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
86+
87+
print(f"Server running on port {port}", file=sys.stderr)
88+
89+
try:
90+
httpd.serve_forever()
91+
except KeyboardInterrupt:
92+
httpd.shutdown()
93+
94+
if __name__ == "__main__":
95+
parser: argparse.ArgumentParser = argparse.ArgumentParser(description='Nix binary cache server with SSL client verification')
96+
parser.add_argument('--port', type=int, default=8443, help='Port to listen on')
97+
parser.add_argument('--cert', required=True, help='Server certificate file')
98+
parser.add_argument('--key', required=True, help='Server private key file')
99+
parser.add_argument('--ca-cert', required=True, help='CA certificate for client verification')
100+
101+
args: argparse.Namespace = parser.parse_args()
102+
103+
run_server(args.port, args.cert, args.key, args.ca_cert)

tests/functional/package.nix

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
jq,
1111
git,
1212
mercurial,
13+
14+
curl,
15+
openssl,
16+
python3,
17+
1318
util-linux,
1419
unixtools,
1520

@@ -56,6 +61,11 @@ mkMesonDerivation (
5661
git
5762
mercurial
5863
unixtools.script
64+
65+
# for store tests
66+
curl
67+
openssl
68+
python3
5969
]
6070
++ lib.optionals stdenv.hostPlatform.isLinux [
6171
# For various sandboxing tests that needs a statically-linked shell,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env bash
2+
3+
# shellcheck source=common.sh
4+
source common.sh
5+
6+
# These are not installed in vm_tests
7+
[[ $(type -p curl) ]] || skipTest "curl is not installed"
8+
[[ $(type -p openssl) ]] || skipTest "openssl is not installed"
9+
[[ $(type -p python3) ]] || skipTest "python3 is not installed"
10+
11+
# Generate test certificates using EC keys for faster generation
12+
13+
# Generate CA with EC key
14+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/ca.key" 2>/dev/null
15+
openssl req -new -x509 -days 1 -key "$TEST_ROOT/ca.key" -out "$TEST_ROOT/ca.crt" \
16+
-subj "/C=US/ST=Test/L=Test/O=TestCA/CN=Test CA" 2>/dev/null
17+
18+
# Generate server certificate with EC key
19+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/server.key" 2>/dev/null
20+
openssl req -new -key "$TEST_ROOT/server.key" -out "$TEST_ROOT/server.csr" \
21+
-subj "/C=US/ST=Test/L=Test/O=TestServer/CN=localhost" 2>/dev/null
22+
openssl x509 -req -days 1 -in "$TEST_ROOT/server.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
23+
-set_serial 01 -out "$TEST_ROOT/server.crt" 2>/dev/null
24+
25+
# Generate client certificate with EC key
26+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/client.key" 2>/dev/null
27+
openssl req -new -key "$TEST_ROOT/client.key" -out "$TEST_ROOT/client.csr" \
28+
-subj "/C=US/ST=Test/L=Test/O=TestClient/CN=Nix Test Client" 2>/dev/null
29+
openssl x509 -req -days 1 -in "$TEST_ROOT/client.csr" -CA "$TEST_ROOT/ca.crt" -CAkey "$TEST_ROOT/ca.key" \
30+
-set_serial 02 -out "$TEST_ROOT/client.crt" 2>/dev/null
31+
32+
# Find a free port
33+
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
34+
35+
# Start the SSL cache server
36+
python3 "${_NIX_TEST_SOURCE_DIR}/nix-binary-cache-ssl-server.py" \
37+
--port "$PORT" \
38+
--cert "$TEST_ROOT/server.crt" \
39+
--key "$TEST_ROOT/server.key" \
40+
--ca-cert "$TEST_ROOT/ca.crt" &
41+
SERVER_PID=$!
42+
43+
# Function to stop server on exit
44+
stopServer() {
45+
kill "$SERVER_PID" 2>/dev/null || true
46+
wait "$SERVER_PID" 2>/dev/null || true
47+
}
48+
trap stopServer EXIT
49+
50+
tries=0
51+
while ! curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
52+
"https://localhost:$PORT/nix-cache-info"; do
53+
if (( tries++ >= 50 )); then
54+
if kill -0 "$SERVER_PID" 2>/dev/null; then
55+
echo "Server started but did not respond in time" >&2
56+
else
57+
echo "Server failed to start" >&2
58+
fi
59+
exit 1
60+
fi
61+
sleep 0.1
62+
done
63+
64+
# Test 1: Verify server rejects connections without client certificate
65+
echo "Testing connection without client certificate (should fail)..." >&2
66+
if curl -s -k "https://localhost:$PORT/nix-cache-info" 2>&1 | grep -q "certificate required"; then
67+
echo "FAIL: Server should have rejected connection" >&2
68+
exit 1
69+
fi
70+
71+
# Test 2: Verify server accepts connections with client certificate
72+
echo "Testing connection with client certificate..." >&2
73+
RESPONSE=$(curl -v -s -k --cert "$TEST_ROOT/client.crt" --key "$TEST_ROOT/client.key" \
74+
"https://localhost:$PORT/nix-cache-info")
75+
76+
if ! echo "$RESPONSE" | grepQuiet "StoreDir: "; then
77+
echo "FAIL: Server should have accepted client certificate: $RESPONSE" >&2
78+
exit 1
79+
fi
80+
81+
# Test 3: Test Nix with SSL client certificate parameters
82+
# Set up substituter URL with SSL parameters
83+
sslCache="https://localhost:$PORT?ssl-cert=$TEST_ROOT/client.crt&ssl-key=$TEST_ROOT/client.key"
84+
85+
# Configure Nix to trust our CA
86+
export NIX_SSL_CERT_FILE="$TEST_ROOT/ca.crt"
87+
88+
# Test nix store info
89+
nix store info --store "$sslCache" --json | jq -e '.url' | grepQuiet "https://localhost:$PORT"
90+
91+
# Test 4: Verify incorrect client certificate is rejected
92+
# Generate a different client cert not signed by our CA (also using EC)
93+
openssl ecparam -genkey -name prime256v1 -out "$TEST_ROOT/wrong.key" 2>/dev/null
94+
openssl req -new -x509 -days 1 -key "$TEST_ROOT/wrong.key" -out "$TEST_ROOT/wrong.crt" \
95+
-subj "/C=US/ST=Test/L=Test/O=Wrong/CN=Wrong Client" 2>/dev/null
96+
97+
wrongCache="https://localhost:$PORT?ssl-cert=$TEST_ROOT/wrong.crt&ssl-key=$TEST_ROOT/wrong.key"
98+
99+
rm -rf "$TEST_HOME"
100+
101+
# This should fail
102+
if nix store info --download-attempts 0 --store "$wrongCache"; then
103+
echo "FAIL: Should have rejected wrong certificate" >&2
104+
exit 1
105+
fi

0 commit comments

Comments
 (0)