Dual cert RSA/ECDSA server with Erlang/OTP 21

Posted 2018-07-03 18:55:58.000000

In my previous post about Erlang/OTP 21 I neglected to mention one change in the ssl application:

OTP-15056    Application(s): ssl

             Deprecate ssl:ssl_accept/[1,2,3] in favour of

At first glance this might look like a mere function rename, no big deal, but it turns out there is more to it than meets the eye. The new :ssl.handshake/[1,2,3] functions support an option to introspect the capabilities of the client and make last-minute adjustments to the server TLS parameters before proceeding with the handshake. One thing this allows us to do is present an ECDSA certificate to clients that can handle it, while falling back to an RSA certificate for those that can’t.

Before diving in, I just want to make this clear: this is not going to work with current versions of Phoenix, Plug, Cowboy, and other servers. This is not a new ssl socket option, it is a change in the way the API works. The old APIs are still there, and most applications will likely continue to use those old APIs for a while, until they drop support for pre-21 OTP versions. So with that out of the way, let’s get started…

Why (not yet) ECDSA?

Modern TLS clients have supported ECDSA certificates for a while now, many Certificate Authorities (CAs) will sign ECDSA certificates, and some operate ECDSA roots and intermediates. But most servers still cannot benefit from ECDSA, because switching from RSA to ECDSA could lead to compatibility issues with older clients.

The solution seems obvious: get both an RSA and an ECDSA certificate. However, until now this was not very practical: prior Erlang/OTP versions could not automatically fall back to the RSA certificate, so it was necessary to run separate RSA and ECDSA endpoints. These endpoints would have to run on different TCP ports or use different hostnames (using the ‘sni_fun’ callback).

Let’s look at how the new handshake function allows the server to select the right certificate dynamically. Please make sure you are on Erlang/OTP 21 if you want to follow along.

Setting up

For starters, we need to generate some certificates. We could use OpenSSL for that, but I think I’ll use this opportunity to plug the X509 package I released yesterday :)

ext = [subject_alt_name: X509.Certificate.Extension.subject_alt_name(["localhost"])]

rsa_key = X509.PrivateKey.new_rsa(4096)
rsa_cert = X509.Certificate.self_signed(rsa_key, "/CN=Test RSA", extensions: ext)
rsa_opts = [
  cert: X509.to_der(rsa_cert),
  key: {:RSAPrivateKey, X509.to_der(rsa_key)}

ec_key = X509.PrivateKey.new_ec(:secp256r1)
ecdsa_cert = X509.Certificate.self_signed(ec_key, "/CN=Test ECDSA", extensions: ext)
ecdsa_opts = [
  cert: X509.to_der(ecdsa_cert),
  key: {:ECPrivateKey, X509.to_der(ec_key)}

The old way

Now let’s start a server on port 8001, using the RSA certificate, and handle an incoming connection with the old API. After the call to transport_accept/1, initiate a connection with a TLS client in another window, e.g. openssl s_client -connect localhost:8001:

{:ok, server} = :ssl.listen(8001, rsa_opts)

{:ok, sock} = :ssl.transport_accept(server)
:ok = :ssl.ssl_accept(sock)
:ssl.connection_information(sock, [:cipher_suite])

The return value from the connection_information/2 function should confirm that we used RSA authentication, probably with ECDHE key exchange (:ecdhe_rsa).

The new API

Now let’s use the new API and see what it gives us. The new API requires that we use a gen_tcp server, so we’ll start one on another TCP port:

{:ok, server} = :gen_tcp.listen(8003)
{:ok, sock} = :gen_tcp.accept(server)
{:ok, sock, hello} = :ssl.handshake(sock, handshake: :hello)

At this point the hello variable contains the client’s capabilities, as extracted from the ClientHello message. For instance, hello.signature_algs contains the supported signature algorithms (atoms for algorithms recognized by ssl, numbers for unsupported algorithms).

To proceed with the handshake we’ll call the handshake_continue/2 function, in this case passing in the ECDSA certificate and key:

{:ok, sock} = :ssl.handshake_continue(sock, ecdsa_opts)
:ssl.connection_information(sock, [:cipher_suite])

Putting it all together

Here’s a code fragment that actually selects the appropriate certificate for the current client:

# Assuming the gen_tcp server is already running
{:ok, sock} =
  with {:ok, tcp_sock} <- :gen_tcp.accept(server),
       {:ok, sock, hello} <- :ssl.handshake(tcp_sock, handshake: :hello) do
    # Check for ECDSA and NIST P-256 curve support
    if Enum.any?(hello.signature_algs, &match?({_, :ecdsa}, &1)) and
        (:pubkey_cert_records.namedCurves(:secp256r1) in hello.elliptic_curves) do
      :ssl.handshake_continue(sock, ecdsa_opts)
      :ssl.handshake_continue(sock, rsa_opts)
:ssl.connection_information(sock, [:cipher_suite])

Try it, first with openssl s_client -connect localhost:8003 and then with openssl s_client -connect localhost:8003 -sigalgs "RSA+SHA256". Note that you’ll need OpenSSL 1.x for that last command; on MacOS X, make sure you’ve got OpenSSL installed through Homebrew and use the binary in /usr/local/opt/openssl/bin/.

Again, it will be a while before we will be able to take full advantage of this new capability in Phoenix/Plug, but it is nice to know that it is coming. And of course, if you’re writing your own servers using the ssl APIs you can start using the new APIs today.