phase 6: TLS dist via inet_tls_dist

make_dist_tls_context() builds an ssl.SSLContext tuned for OTP defaults
(verify_peer, mTLS, TLSv1.2 minimum). Node accepts tls_context= and
wraps the TCP socket in TLS before the v6 handshake runs.

Critical quirk found by experimentation: inet_tls_dist uses {packet, 4}
on the SSL socket during the handshake. Plain inet_tcp_dist uses
{packet, 2} for handshake then switches to {packet, 4} post-nodeup.
handshake() now takes a frame_size= kwarg (2 or 4); Node auto-selects 4
whenever tls_context is supplied.

Cert requirements (found by experimentation against Erlang E2E):
  - CA cert with basicConstraints CA:TRUE
  - Leaf certs with SAN including the dist hostname (and localhost)
  - extendedKeyUsage covering both serverAuth and clientAuth

Tests:
  - make_dist_tls_context unit tests
  - Live: spawn erl -proto_dist inet_tls with SAN-bearing certs,
    Node.call(gen_target, {ping, 99}) round-trips through the tunnel
  - Live negative: plaintext connection to TLS-only peer must fail
  - Live negative: client cert from a different CA must fail

115 tests green across 5 consecutive runs, lint clean.
This commit is contained in:
russell@unturf.com 2026-06-16 12:06:07 -04:00
parent ac4636e00c
commit 271d712237
No known key found for this signature in database
7 changed files with 416 additions and 18 deletions

296
tests/test_tls.py Normal file
View file

@ -0,0 +1,296 @@
"""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,
)