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:
parent
ac4636e00c
commit
271d712237
7 changed files with 416 additions and 18 deletions
296
tests/test_tls.py
Normal file
296
tests/test_tls.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue