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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
erl_crash.dump
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,20 @@ if we hit a peer that requires it.
|
||||||
responder, drop-on-the-floor against an unknown registered name,
|
responder, drop-on-the-floor against an unknown registered name,
|
||||||
Ref uniqueness across the Node lifetime
|
Ref uniqueness across the Node lifetime
|
||||||
|
|
||||||
## Phase 6 — TLS dist
|
## Phase 6 — TLS dist ✅
|
||||||
|
|
||||||
- Wrap the post-EPMD socket in TLS
|
- `make_dist_tls_context(cert=, key=, ca=)` helper builds an
|
||||||
- Match `inet_tls_dist` config on the Erlang side (cert + key + ca paths)
|
`ssl.SSLContext` tuned for OTP defaults (verify_peer, TLSv1.2 min)
|
||||||
- Same handshake, just runs inside the TLS tunnel
|
- `Node(..., tls_context=ctx)` wraps the post-EPMD socket in TLS
|
||||||
|
before the dist handshake
|
||||||
|
- Auto-selects `frame_size=4` for the handshake when TLS is in use
|
||||||
|
(the non-obvious quirk: `inet_tls_dist` uses `{packet, 4}` on the
|
||||||
|
SSL socket, where plain `inet_tcp_dist` uses `{packet, 2}` during
|
||||||
|
handshake)
|
||||||
|
- Live tests: generate CA + SAN-bearing certs with openssl, spawn
|
||||||
|
`erl -proto_dist inet_tls`, drive a full `Node.call()` through
|
||||||
|
the tunnel. Negative tests confirm: plaintext connection fails,
|
||||||
|
wrong-CA client cert fails.
|
||||||
|
|
||||||
## Phase 7 — unfeed integration
|
## Phase 7 — unfeed integration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from erldistpy.etf import (
|
||||||
)
|
)
|
||||||
from erldistpy.handshake import HandshakeError, HandshakeResult, handshake
|
from erldistpy.handshake import HandshakeError, HandshakeResult, handshake
|
||||||
from erldistpy.node import CallProtocolError, CallTimeout, Node, NodeError
|
from erldistpy.node import CallProtocolError, CallTimeout, Node, NodeError
|
||||||
|
from erldistpy.tls import make_dist_tls_context
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Atom",
|
"Atom",
|
||||||
|
|
@ -34,4 +35,5 @@ __all__ = [
|
||||||
"encode",
|
"encode",
|
||||||
"handshake",
|
"handshake",
|
||||||
"lookup",
|
"lookup",
|
||||||
|
"make_dist_tls_context",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,19 @@ def handshake(
|
||||||
our_flags: int = DEFAULT_FLAGS,
|
our_flags: int = DEFAULT_FLAGS,
|
||||||
our_creation: int | None = None,
|
our_creation: int | None = None,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
|
frame_size: int = 2,
|
||||||
) -> HandshakeResult:
|
) -> HandshakeResult:
|
||||||
"""Drive ``sock`` through a v6 distribution handshake.
|
"""Drive ``sock`` through a v6 distribution handshake.
|
||||||
|
|
||||||
``our_name`` is the full ``name@host`` we are calling as.
|
``our_name`` is the full ``name@host`` we are calling as.
|
||||||
``cookie`` is the shared secret with the peer.
|
``cookie`` is the shared secret with the peer.
|
||||||
|
|
||||||
|
``frame_size`` is the length-prefix size in bytes — 2 for plain TCP
|
||||||
|
dist (``inet_tcp_dist``, the default), 4 for TLS dist
|
||||||
|
(``inet_tls_dist``, which sets ``{packet, 4}`` on the SSL socket
|
||||||
|
before the handshake). Pass ``4`` whenever the socket is wrapped in
|
||||||
|
TLS; :class:`Node` selects this automatically based on ``tls_context``.
|
||||||
|
|
||||||
On success the socket is left open and authenticated. On any
|
On success the socket is left open and authenticated. On any
|
||||||
protocol or auth failure raises :class:`HandshakeError`.
|
protocol or auth failure raises :class:`HandshakeError`.
|
||||||
"""
|
"""
|
||||||
|
|
@ -70,22 +77,24 @@ def handshake(
|
||||||
raise ValueError(f"our_name must look like 'name@host', got {our_name!r}")
|
raise ValueError(f"our_name must look like 'name@host', got {our_name!r}")
|
||||||
if not (our_flags & DFLAG_HANDSHAKE_23):
|
if not (our_flags & DFLAG_HANDSHAKE_23):
|
||||||
raise ValueError("our_flags must include DFLAG_HANDSHAKE_23 (v6 handshake)")
|
raise ValueError("our_flags must include DFLAG_HANDSHAKE_23 (v6 handshake)")
|
||||||
|
if frame_size not in (2, 4):
|
||||||
|
raise ValueError(f"frame_size must be 2 or 4, got {frame_size}")
|
||||||
if our_creation is None:
|
if our_creation is None:
|
||||||
our_creation = _random_creation()
|
our_creation = _random_creation()
|
||||||
|
|
||||||
sock.settimeout(timeout)
|
sock.settimeout(timeout)
|
||||||
|
|
||||||
# Step 1: send_name
|
# Step 1: send_name
|
||||||
_send_frame(sock, build_send_name(our_name, our_flags, our_creation))
|
_send_frame(sock, build_send_name(our_name, our_flags, our_creation), frame_size)
|
||||||
|
|
||||||
# Step 2: recv_status
|
# Step 2: recv_status
|
||||||
status_frame = _recv_frame(sock)
|
status_frame = _recv_frame(sock, frame_size)
|
||||||
status = parse_recv_status(status_frame)
|
status = parse_recv_status(status_frame)
|
||||||
if status not in ("ok", "ok_simultaneous"):
|
if status not in ("ok", "ok_simultaneous"):
|
||||||
raise HandshakeError(f"peer rejected handshake: status={status!r}")
|
raise HandshakeError(f"peer rejected handshake: status={status!r}")
|
||||||
|
|
||||||
# Step 3: recv_challenge
|
# Step 3: recv_challenge
|
||||||
challenge_frame = _recv_frame(sock)
|
challenge_frame = _recv_frame(sock, frame_size)
|
||||||
peer_flags, peer_challenge, peer_creation, peer_name = parse_recv_challenge(challenge_frame)
|
peer_flags, peer_challenge, peer_creation, peer_name = parse_recv_challenge(challenge_frame)
|
||||||
if not (peer_flags & DFLAG_HANDSHAKE_23):
|
if not (peer_flags & DFLAG_HANDSHAKE_23):
|
||||||
raise HandshakeError("peer does not speak v6 handshake (no DFLAG_HANDSHAKE_23)")
|
raise HandshakeError("peer does not speak v6 handshake (no DFLAG_HANDSHAKE_23)")
|
||||||
|
|
@ -93,10 +102,10 @@ def handshake(
|
||||||
# Step 4: send_challenge_reply with our challenge + MD5(cookie || peer_challenge)
|
# Step 4: send_challenge_reply with our challenge + MD5(cookie || peer_challenge)
|
||||||
our_challenge = _random_challenge()
|
our_challenge = _random_challenge()
|
||||||
reply_digest = cookie_digest(cookie, peer_challenge)
|
reply_digest = cookie_digest(cookie, peer_challenge)
|
||||||
_send_frame(sock, build_challenge_reply(our_challenge, reply_digest))
|
_send_frame(sock, build_challenge_reply(our_challenge, reply_digest), frame_size)
|
||||||
|
|
||||||
# Step 5: recv_challenge_ack — peer must answer with MD5(cookie || our_challenge)
|
# Step 5: recv_challenge_ack — peer must answer with MD5(cookie || our_challenge)
|
||||||
ack_frame = _recv_frame(sock)
|
ack_frame = _recv_frame(sock, frame_size)
|
||||||
ack_digest = parse_challenge_ack(ack_frame)
|
ack_digest = parse_challenge_ack(ack_frame)
|
||||||
expected = cookie_digest(cookie, our_challenge)
|
expected = cookie_digest(cookie, our_challenge)
|
||||||
if ack_digest != expected:
|
if ack_digest != expected:
|
||||||
|
|
@ -187,15 +196,21 @@ def cookie_digest(cookie: str, challenge: int) -> bytes:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _send_frame(sock: socket.socket, body: bytes) -> None:
|
def _send_frame(sock: socket.socket, body: bytes, frame_size: int = 2) -> None:
|
||||||
if len(body) > 0xFFFF:
|
if frame_size == 2:
|
||||||
raise HandshakeError(f"handshake frame too large: {len(body)} bytes")
|
if len(body) > 0xFFFF:
|
||||||
sock.sendall(struct.pack(">H", len(body)) + body)
|
raise HandshakeError(f"handshake frame too large for 2-byte length: {len(body)} bytes")
|
||||||
|
sock.sendall(struct.pack(">H", len(body)) + body)
|
||||||
|
else:
|
||||||
|
sock.sendall(struct.pack(">I", len(body)) + body)
|
||||||
|
|
||||||
|
|
||||||
def _recv_frame(sock: socket.socket) -> bytes:
|
def _recv_frame(sock: socket.socket, frame_size: int = 2) -> bytes:
|
||||||
hdr = _recv_exact(sock, 2)
|
hdr = _recv_exact(sock, frame_size)
|
||||||
(length,) = struct.unpack(">H", hdr)
|
if frame_size == 2:
|
||||||
|
(length,) = struct.unpack(">H", hdr)
|
||||||
|
else:
|
||||||
|
(length,) = struct.unpack(">I", hdr)
|
||||||
if length == 0:
|
if length == 0:
|
||||||
return b""
|
return b""
|
||||||
return _recv_exact(sock, length)
|
return _recv_exact(sock, length)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from erldistpy.channel import (
|
from erldistpy.channel import (
|
||||||
|
|
@ -59,7 +60,16 @@ class Node:
|
||||||
cookie: str,
|
cookie: str,
|
||||||
peer_host: str = "localhost",
|
peer_host: str = "localhost",
|
||||||
connect_timeout: float = 5.0,
|
connect_timeout: float = 5.0,
|
||||||
|
tls_context: ssl.SSLContext | None = None,
|
||||||
|
tls_server_hostname: str | None = None,
|
||||||
):
|
):
|
||||||
|
"""Open a dist connection to the peer.
|
||||||
|
|
||||||
|
If ``tls_context`` is supplied, the TCP socket gets wrapped in
|
||||||
|
TLS before the v6 handshake runs. EPMD lookup stays in plaintext
|
||||||
|
(EPMD never negotiates TLS itself). Use
|
||||||
|
:func:`erldistpy.tls.make_dist_tls_context` to build the context.
|
||||||
|
"""
|
||||||
if "@" not in our_name:
|
if "@" not in our_name:
|
||||||
raise ValueError(f"our_name must look like 'name@host', got {our_name!r}")
|
raise ValueError(f"our_name must look like 'name@host', got {our_name!r}")
|
||||||
self.our_name = our_name
|
self.our_name = our_name
|
||||||
|
|
@ -67,21 +77,33 @@ class Node:
|
||||||
self.peer_host = peer_host
|
self.peer_host = peer_host
|
||||||
self._cookie = cookie # never logged
|
self._cookie = cookie # never logged
|
||||||
self._our_atom = Atom(our_name)
|
self._our_atom = Atom(our_name)
|
||||||
|
self.tls = tls_context is not None
|
||||||
|
|
||||||
info = lookup(peer_name, host=peer_host, timeout=connect_timeout)
|
info = lookup(peer_name, host=peer_host, timeout=connect_timeout)
|
||||||
if info is None:
|
if info is None:
|
||||||
raise NodeError(f"peer node {peer_name!r} not registered with EPMD on {peer_host}")
|
raise NodeError(f"peer node {peer_name!r} not registered with EPMD on {peer_host}")
|
||||||
sock = socket.create_connection((peer_host, info.port), timeout=connect_timeout)
|
raw_sock = socket.create_connection((peer_host, info.port), timeout=connect_timeout)
|
||||||
try:
|
try:
|
||||||
|
if tls_context is not None:
|
||||||
|
sock: socket.socket = tls_context.wrap_socket(
|
||||||
|
raw_sock,
|
||||||
|
server_hostname=tls_server_hostname,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sock = raw_sock
|
||||||
|
# inet_tls_dist sets {packet, 4} on the SSL socket pre-nodeup;
|
||||||
|
# inet_tcp_dist uses {packet, 2}. Match the wire format.
|
||||||
|
frame_size = 4 if tls_context is not None else 2
|
||||||
result: HandshakeResult = handshake(
|
result: HandshakeResult = handshake(
|
||||||
sock,
|
sock,
|
||||||
our_name=our_name,
|
our_name=our_name,
|
||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
our_flags=DEFAULT_FLAGS,
|
our_flags=DEFAULT_FLAGS,
|
||||||
timeout=connect_timeout,
|
timeout=connect_timeout,
|
||||||
|
frame_size=frame_size,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
sock.close()
|
raw_sock.close()
|
||||||
raise
|
raise
|
||||||
self._sock = sock
|
self._sock = sock
|
||||||
self._channel = Channel(sock, recv_timeout=None)
|
self._channel = Channel(sock, recv_timeout=None)
|
||||||
|
|
|
||||||
53
erldistpy/tls.py
Normal file
53
erldistpy/tls.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""TLS helper for distribution over ``inet_tls_dist``.
|
||||||
|
|
||||||
|
Erlang's ``inet_tls_dist`` runs the distribution protocol inside a
|
||||||
|
mutual-TLS tunnel. The wire flow is mostly unchanged after the tunnel
|
||||||
|
exists — EPMD still hands out the listener port in plaintext, then the
|
||||||
|
TCP connect to that port is immediately wrapped in TLS, then the v6
|
||||||
|
handshake runs over the wrapped socket.
|
||||||
|
|
||||||
|
One non-obvious quirk worth knowing: ``inet_tls_dist`` sets
|
||||||
|
``{packet, 4}`` on the SSL socket pre-nodeup (plain ``inet_tcp_dist``
|
||||||
|
uses ``{packet, 2}`` for the handshake then switches to ``{packet, 4}``
|
||||||
|
post-nodeup). Our :func:`erldistpy.handshake.handshake` takes a
|
||||||
|
``frame_size=`` kwarg; :class:`erldistpy.node.Node` passes ``4``
|
||||||
|
automatically whenever a ``tls_context`` is supplied.
|
||||||
|
|
||||||
|
This module exposes one helper, :func:`make_dist_tls_context`, which
|
||||||
|
builds an :class:`ssl.SSLContext` tuned for the OTP server's defaults
|
||||||
|
(verify_peer, fail_if_no_peer_cert). Pass it into :class:`Node` via the
|
||||||
|
``tls_context`` kwarg.
|
||||||
|
|
||||||
|
Secrets discipline: ``cert`` / ``key`` / ``ca`` are file *paths*. We
|
||||||
|
never read PEM contents into Python — :class:`ssl.SSLContext` loads
|
||||||
|
them through OpenSSL directly. Caller's job to keep ``key`` readable
|
||||||
|
only by the running process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
|
||||||
|
def make_dist_tls_context(
|
||||||
|
*,
|
||||||
|
cert: str,
|
||||||
|
key: str,
|
||||||
|
ca: str,
|
||||||
|
check_hostname: bool = False,
|
||||||
|
minimum_version: ssl.TLSVersion = ssl.TLSVersion.TLSv1_2,
|
||||||
|
) -> ssl.SSLContext:
|
||||||
|
"""Build an SSLContext for a TLS-dist client.
|
||||||
|
|
||||||
|
Defaults match the OTP server side (which verifies the client cert
|
||||||
|
and requires one). ``check_hostname=False`` because dist nodes are
|
||||||
|
identified by their cookie + cert chain, not by SNI hostname; flip
|
||||||
|
on if your CA pins per-node CNs.
|
||||||
|
"""
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = check_hostname
|
||||||
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
ctx.minimum_version = minimum_version
|
||||||
|
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
||||||
|
ctx.load_verify_locations(cafile=ca)
|
||||||
|
return ctx
|
||||||
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