Erlang TLS Distribution

12 Nov 2022 14:29 erlang

In the previous post, I recapped Erlang distribution (clustering). In this post, we’ll secure it by using TLS.

By default, Erlang uses a plain-text TCP protocol for distribution, which means it’s not secure. It supports using TLS. This post will explore how to use that.

The documentation’s here, but it’s quite dense. I’m going to come at it from a different angle.

Recall that in the previous post, we’d got two nodes, each on a different host, talking to each other.

I’m using Erlang/OTP 25.0.4. This stuff will probably work on Erlang/OTP 24.x.

Specifying Distribution Module for net_kernel

This time, let’s turn on TLS distribution with -proto_dist inet_tls:

roger-nuc1$ erl -sname demo -setcookie KMZWIWWTBVPEBURCLHVQ -proto_dist inet_tls
(demo@roger-nuc1)1>
roger-nuc2$ erl -sname demo -setcookie KMZWIWWTBVPEBURCLHVQ -proto_dist inet_tls
(demo@roger-nuc2)1>

This time when we attempt to connect them, well, it’s not pretty:

(demo@roger-nuc2)2> net_kernel:connect_node('demo@roger-nuc1').
=WARNING REPORT==== 12-Nov-2022::14:38:05.798472 ===
Description: "Authenticity is not established by certificate path validation"
     Reason: "Option {verify, verify_peer} and cacertfile/cacerts is missing"

That’s bad enough, but on the other node, I get several pages of error logging:

(demo@roger-nuc1)1> =ERROR REPORT==== 12-Nov-2022::14:38:05.921992 ===
** State machine <0.102.0> terminating
** Last event = {internal,
                    {client_hello,
                        {3,3},
                        <<78,188,119,203,5,224,249,70,195,11,72,92,218,219,56,
                          77,170,207,16,67,176,166,188,143,91,129,82,8,119,108,
                          158,198>>,
                        <<>>,undefined,

…and so on.

Specifying TLS Options

We’ll need to specify some TLS options by using the -ssl_dist_optfile option. It takes a file name containing the options. Let’s start with this; I’m going to call it inet_tls.conf:

[
  {server, [
    {certfile, "server.crt"},
    {keyfile, "server.key"},
    {secure_renegotiate, true}
  ]},
  {client, [
    {secure_renegotiate, true}
  ]}
].

Note that this doesn’t use the correct server name, and doesn’t do client authentication. Let’s just get encryption working first. We’ll worry about mutual authentication later.

The documentation also says that the certfile must be a PEM file containing both the certificate and key. This isn’t true; you can use certfile and keyfile to specify them separately.

Note that you can pass the options on the command line using -ssl_dist_opt, but the documentation says that’s legacy, and I’d prefer a file anyway, because (later) when I’m doing this in Kubernetes, I can use a ConfigMap.

We’re going to need a server certificate. I’ll use my elixir-certs script:

./certs self-signed \
    --out-cert inet-tls-ca.crt --out-key inet-tls-ca.key \
    --template root-ca \
    --subject "/C=GB/L=London/O=differentpla.net/CN=differentpla.net inet_tls CA"

./certs create-cert \
    --issuer-cert inet-tls-ca.crt --issuer-key inet-tls-ca.key \
    --out-cert server.crt --out-key server.key \
    --template server \
    --subject '/CN=server'

We’ll need the inet_tls.conf, server.crt and server.key files on both hosts. You can use (e.g.) SSH to copy them; I just copy-pasted between two terminal windows.

roger-nuc1$ erl -sname demo -setcookie KMZWIWWTBVPEBURCLHVQ -proto_dist inet_tls -ssl_dist_optfile "$PWD/inet_tls.conf"
(demo@roger-nuc1)1>
roger-nuc2$ erl -sname demo -setcookie KMZWIWWTBVPEBURCLHVQ -proto_dist inet_tls -ssl_dist_optfile "$PWD/inet_tls.conf"
(demo@roger-nuc2)1>

Then we can connect the two nodes:

(demo@roger-nuc1)1> net_kernel:connect_node('demo@roger-nuc2').
=WARNING REPORT==== 12-Nov-2022::15:28:28.249514 ===
Description: "Authenticity is not established by certificate path validation"
     Reason: "Option {verify, verify_peer} and cacertfile/cacerts is missing"

true
(demo@roger-nuc1)2> nodes().
['demo@roger-nuc2']

Done. It’s encrypted, but it’s not authenticated (that’s what the WARNING REPORT is complaining about).

Server authentication

We can turn on server authentication by adding {verify, verify_peer} and cacertfile options to the client section of the inet_tls.conf file as follows:

[
  {server, [
    {certfile, "server.crt"},
    {keyfile, "server.key"},
    {secure_renegotiate, true}
  ]},
  {client, [
    {verify, verify_peer},
    {cacertfile, "inet-tls-ca.crt"},
    {secure_renegotiate, true}
  ]}
].

…but now clustering doesn’t work:

(demo@roger-nuc1)1> net_kernel:connect_node('demo@roger-nuc2').
false

To fix that, we’ll need some server certificates that actually have the correct server name in them:

./certs create-cert \
    --issuer-cert inet-tls-ca.crt --issuer-key inet-tls-ca.key \
    --out-cert roger-nuc1.crt --out-key roger-nuc1.key \
    --template server \
    --subject '/CN=roger-nuc1'

./certs create-cert \
    --issuer-cert inet-tls-ca.crt --issuer-key inet-tls-ca.key \
    --out-cert roger-nuc2.crt --out-key roger-nuc2.key \
    --template server \
    --subject '/CN=roger-nuc2'

Ideally, you’d keep the CA key separate and secure, and you’d use certificate signing requests and all of that jazz. I don’t feel like jumping through all of those hoops for a demo.

Copy the server certificates and keys to the correct hosts. You’ll also need the CA certificate (but not the key) on both hosts.

Then you need to ensure that the node is using the correct certificate files. I’m going to do some envsubst stuff.

First we need to rename inet_tls.conf to inet_tls.conf.template, and edit it so that it looks like the following:

[
  {server, [
    {certfile, "${HOSTNAME}.crt"},
    {keyfile, "${HOSTNAME}.key"},
    {secure_renegotiate, true}
  ]},
  {client, [
    {verify, verify_peer},
    {cacertfile, "inet-tls-ca.crt"},
    {secure_renegotiate, true}
  ]}
].

Then we need a startup script. I’m calling mine run.sh:

#!/bin/bash

export HOSTNAME="$(hostname -s)"

envsubst < inet_tls.conf.template > "inet_tls.$HOSTNAME.conf"

erl -sname demo \
    -setcookie KMZWIWWTBVPEBURCLHVQ \
    -proto_dist inet_tls \
    -ssl_dist_optfile "$PWD/inet_tls.$HOSTNAME.conf"

…and that all works:

$ ./run.sh
Erlang/OTP 25 [erts-13.0.4] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Eshell V13.0.4  (abort with ^G)
(demo@roger-nuc1)1> net_kernel:connect_node('demo@roger-nuc2').
true
(demo@roger-nuc1)2> nodes().
['demo@roger-nuc2']

Aside: Using an Intermediate CA

If you’re using an intermediate CA (this is not currently supported by my certs script), you’ll need to put that in the server certificate file. You don’t need to include the root CA certificate.

It should come after the server certificate (see RFC 4346):

cat roger-nuc1.crt intermediate-ca.crt > roger-nuc1.pem

Client Authentication

It’s still not mutual authentication, though. Let’s sort that out. Basically, we need to make the client and server sections of the config file look the same:

[
  {server, [
    {certfile, "${HOSTNAME}.crt"},
    {keyfile, "${HOSTNAME}.key"},
    {verify, verify_peer},
    {cacertfile, "inet-tls-ca.crt"},
    {secure_renegotiate, true}
  ]},
  {client, [
    {certfile, "${HOSTNAME}.crt"},
    {keyfile, "${HOSTNAME}.key"},
    {verify, verify_peer},
    {cacertfile, "inet-tls-ca.crt"},
    {secure_renegotiate, true}
  ]}
].

And that all works.