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.
296 lines
8.9 KiB
Python
296 lines
8.9 KiB
Python
"""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,
|
|
)
|