erldistpy/erldistpy/tls.py
russell@unturf.com 271d712237
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.
2026-06-16 12:06:07 -04:00

53 lines
2 KiB
Python

"""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