"""TLS dist tests. Unit tests cover the SSLContext builder. Live tests generate a CA + server/client certs with openssl, spawn ``erl -proto_dist inet_tls``, and prove a full Node.call() round-trips through the TLS tunnel. Two non-obvious requirements found by experimentation: 1. ``inet_tls_dist`` uses ``{packet, 4}`` on the SSL socket for the handshake (plain TCP dist uses ``{packet, 2}``). Handshake length prefix is 4 bytes over TLS. 2. Erlang validates the peer cert's hostname; certs need a SAN including the dist node hostname (``hostname`` from ``uname -n``) and/or ``localhost``. """ from __future__ import annotations import os import shutil import socket import ssl import subprocess import time from pathlib import Path import pytest from erldistpy.epmd import lookup from erldistpy.etf import Atom from erldistpy.handshake import HandshakeError from erldistpy.node import Node from erldistpy.tls import make_dist_tls_context # -------------------------------------------------------------------------- # Pure # -------------------------------------------------------------------------- def test_make_dist_tls_context_missing_files(tmp_path): with pytest.raises((FileNotFoundError, ssl.SSLError)): make_dist_tls_context( cert=str(tmp_path / "missing.pem"), key=str(tmp_path / "missing.key"), ca=str(tmp_path / "missing-ca.pem"), ) # -------------------------------------------------------------------------- # Live — generate certs, spawn erl with inet_tls_dist, call() over TLS # -------------------------------------------------------------------------- READY_FLAG = "/tmp/erldistpy_test_tls_ready" SNAME = "erldistpy_tls" COOKIE = "ERLDISTPY_TLS_COOKIE" ERL_GEN_BOOT = ( "Handle = fun " " ({ping, X}) -> {pong, X}; " " (Other) -> {error, Other} " "end, " "Loop = fun(F) -> " " receive " " {'$gen_call', {From, Ref}, Request} -> " " From ! {Ref, Handle(Request)}, F(F) " " end " "end, " "Pid = spawn(fun() -> Loop(Loop) end), " "register(gen_target, Pid), " f'file:write_file("{READY_FLAG}", "1"), ' "timer:sleep(infinity)." ) def _epmd_running() -> bool: try: with socket.create_connection(("localhost", 4369), timeout=0.5): return True except OSError: return False def _gen_certs(workdir: Path) -> dict[str, str]: """Generate a CA + server + client cert with SAN matching the hostname. Erlang's TLS-dist validates the peer cert's hostname during handshake; without a matching SAN it rejects the cert chain (``{bad_cert,hostname_check_failed}``). """ hostname = socket.gethostname() # CA with proper CA:TRUE extension ca_cnf = workdir / "ca.cnf" ca_cnf.write_text( "[req]\n" "distinguished_name = dn\n" "x509_extensions = v3_ca\n" "prompt = no\n" "[dn]\n" "CN = erldistpy-test-ca\n" "[v3_ca]\n" "basicConstraints = critical,CA:TRUE\n" "keyUsage = critical,keyCertSign,cRLSign\n" "subjectKeyIdentifier = hash\n" ) subprocess.run( [ "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", str(workdir / "ca.key"), "-out", str(workdir / "ca.pem"), "-days", "1", "-config", str(ca_cnf), "-extensions", "v3_ca", ], check=True, capture_output=True, ) os.chmod(workdir / "ca.key", 0o600) # Leaf cert extensions including SAN leaf_cnf = workdir / "leaf.cnf" leaf_cnf.write_text( "[v3]\n" "basicConstraints = CA:FALSE\n" "keyUsage = digitalSignature,keyEncipherment\n" "extendedKeyUsage = serverAuth,clientAuth\n" "subjectAltName = @alt\n" "[alt]\n" f"DNS.1 = {hostname}\n" "DNS.2 = localhost\n" ) def _signed_cert(name: str) -> None: subprocess.run( [ "openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", str(workdir / f"{name}.key"), "-out", str(workdir / f"{name}.csr"), "-subj", f"/CN={hostname}", ], check=True, capture_output=True, ) subprocess.run( [ "openssl", "x509", "-req", "-in", str(workdir / f"{name}.csr"), "-CA", str(workdir / "ca.pem"), "-CAkey", str(workdir / "ca.key"), "-CAcreateserial", "-out", str(workdir / f"{name}.pem"), "-days", "1", "-extfile", str(leaf_cnf), "-extensions", "v3", ], check=True, capture_output=True, ) os.chmod(workdir / f"{name}.key", 0o600) _signed_cert("server") _signed_cert("client") return { "ca": str(workdir / "ca.pem"), "server_cert": str(workdir / "server.pem"), "server_key": str(workdir / "server.key"), "client_cert": str(workdir / "client.pem"), "client_key": str(workdir / "client.key"), } def _write_ssl_config(workdir: Path, certs: dict[str, str]) -> str: """Write the ssl_dist_opts.config file Erlang reads.""" cfg = workdir / "ssl_dist.config" body = ( "[{server, " f'[{{certfile, "{certs["server_cert"]}"}}, ' f'{{keyfile, "{certs["server_key"]}"}}, ' f'{{cacertfile, "{certs["ca"]}"}}, ' "{verify, verify_peer}, " "{fail_if_no_peer_cert, true}]}, " "{client, " f'[{{certfile, "{certs["server_cert"]}"}}, ' f'{{keyfile, "{certs["server_key"]}"}}, ' f'{{cacertfile, "{certs["ca"]}"}}, ' "{verify, verify_peer}]}]." ) cfg.write_text(body) return str(cfg) @pytest.fixture(scope="module") def tls_peer(tmp_path_factory): if not shutil.which("erl"): pytest.skip("erl not installed") if not shutil.which("openssl"): pytest.skip("openssl not installed") if not _epmd_running(): pytest.skip("EPMD not running") if os.path.exists(READY_FLAG): os.remove(READY_FLAG) workdir = tmp_path_factory.mktemp("tls") certs = _gen_certs(workdir) ssl_config = _write_ssl_config(workdir, certs) proc = subprocess.Popen( [ "erl", "-sname", SNAME, "-setcookie", COOKIE, "-proto_dist", "inet_tls", "-ssl_dist_optfile", ssl_config, "-noshell", "-eval", ERL_GEN_BOOT, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) deadline = time.monotonic() + 8.0 ready = False while time.monotonic() < deadline: time.sleep(0.15) if lookup(SNAME, timeout=0.5) is None: continue if os.path.exists(READY_FLAG): ready = True break if not ready: proc.terminate() proc.wait(timeout=3) pytest.skip("erl TLS-dist node did not become ready") yield SNAME, COOKIE, certs proc.terminate() try: proc.wait(timeout=3) except subprocess.TimeoutExpired: proc.kill() if os.path.exists(READY_FLAG): os.remove(READY_FLAG) def _client_name() -> str: return f"erldistpy_tls_client@{socket.gethostname()}" def test_call_over_tls(tls_peer): sname, cookie, certs = tls_peer ctx = make_dist_tls_context( cert=certs["client_cert"], key=certs["client_key"], ca=certs["ca"], ) with Node( our_name=_client_name(), peer_name=sname, cookie=cookie, tls_context=ctx, ) as node: assert node.tls is True reply = node.call("gen_target", (Atom("ping"), 99)) assert reply == (Atom("pong"), 99) def test_plaintext_connection_to_tls_peer_fails(tls_peer): """Connecting without TLS to a node that requires TLS must fail — that's the security boundary the tunnel exists to enforce.""" sname, cookie, _ = tls_peer with pytest.raises((HandshakeError, OSError, ssl.SSLError, ConnectionError)): Node( our_name=_client_name(), peer_name=sname, cookie=cookie, connect_timeout=2.0, ) def test_wrong_ca_fails(tls_peer, tmp_path): """A client with a cert signed by a CA the server doesn't trust must fail to handshake.""" sname, cookie, certs = tls_peer # Generate a totally separate CA + client cert in a fresh dir other = tmp_path / "other" other.mkdir() other_certs = _gen_certs(other) bad_ctx = make_dist_tls_context( cert=other_certs["client_cert"], key=other_certs["client_key"], ca=certs["ca"], # we trust the right CA, but our cert isn't signed by it ) with pytest.raises((ssl.SSLError, OSError, ConnectionError, HandshakeError)): Node( our_name=_client_name(), peer_name=sname, cookie=cookie, tls_context=bad_ctx, connect_timeout=3.0, )