In this post, I’ll describe our approach to correctly forcing SSL verification when establishing PostgreSQL DB connections from an Elixir application, which we did as a way of adding an additional security layer to our DB to prevent threats such as spoofing. What we thought was a trivial task actually ended up needing a lot of trial and error, but we were finally able to come up with the correct approach.
First, I’ll describe the different parts involved in our problem, and then I’ll jump right to the implementation adopted.
TCP Connections on PostgreSQL
The PostgreSQL RDBMS uses its own message-based protocol for communication between clients and database servers. This protocol can run over TCP/IP and IPC sockets. The PostgreSQL team even registered 5432 with IANA as a port number for its protocol.
This protocol supports SSL-encrypted connections, and in turn, Postgrex
— the official PostgreSQL driver for Elixir — has support for them.
SSL
A little bit of clarification before continuing: SSL (Secure Sockets Layer) and TLS (Transport Layer Security) are names that nowadays are used interchangeably. The original SSL protocol is deprecated, and TLS is its successor, but since what ended up being TLS was originally planned as version 3.1 of SSL, the old name is still used.
TLS is a security protocol that adds an encryption layer to internet communications. Its most common application is securing the data transfer between web clients and servers operating under the HTTP protocol, which results in the HTTPS implementation. But in reality, it can be used in combination with other protocols, such as in the case of its use with the PostgreSQL protocol.
Erlang ships with its own implementation of functions for TLS protocol support under the :ssl
module.
Postgrex SSL Configuration Settings
The Ecto.Adapters.Postgres
adapter module for Ecto SQL uses Postgrex
for communicating with the database. Postgrex
supports SSL connections to the underlying PostgreSQL server via the :ssl
configuration option. If :ssl
is set to true
, :ssl_opts
are expected, e.g.:
config :my_app, MyApp.Repo, ssl: true, ssl_opts: [ # Options go here. ]
By default, even when SSL is used to establish connections to the DB, PostgreSQL doesn’t perform any verification of the server certificate. However, additional verification of the server’s identity via a chain of trust is needed to prevent spoofing.
For the client — in this case, our Elixir application — to verify the DB server certificate, a root certificate must be specified. The server needs to be configured with a leaf certificate signed by the very same root certificate. Ideally, the root certificate should come from a trusted certificate authority.
ℹ️ Note: It’s also possible for the PostgreSQL server to verify the identity of the client, but that scenario is beyond the scope of this blog post.
One way of specifying the root certificate as part of the Ecto configuration is via the :cacertfile
option, which expects the path to a single PEM-encoded file, which in turn might also bundle intermediate certificates if needed, e.g.:
config :my_app, MyApp.Repo, ssl: true, ssl_opts: [ cacertfile: "root_cert.pem" ]
To verify the host, the :verify
option should be set to :verify_peer
. Additionally, the Erlang ssl
docs state:
If the option
verify
is set toverify_peer
the optionserver_name_indication
shall also be specified […]
As such, we need to also add :server_name_indication
into the mix. :server_name_indication
is an Erlang string()
that should match whatever is set as the certificate’s Subject Alternative Name attribute(s) — or the Common Name attribute if no Subject Alternative Name is present.
💡 Tip: One way to check the attributes in a certificate is via OpenSSL.
SAN
refers to the Subject Alternative Name field (or fields, since multiple fields are allowed), whereasCN
refers to the Common Name field. Run the following OpenSSL command with a PEM-encoded certificate file as an input to check its attributes:openssl x509 -in cert.crt -text -noout
.
Notice the important distinction that :server_name_indication
is an Erlang string. In Elixir, strings are UTF-8 encoded binaries, whereas in Erlang, strings refer to what are known as char lists in Elixir. Hence, to convert an Elixir string to a char list, we need to use Elixir’s Kernel.to_charlist/1
function:
db_host = "example.com" config :my_app, MyApp.Repo, ssl: true, ssl_opts: [ cacertfile: "root_cert.pem", verify: :verify_peer, server_name_indication: to_charlist(db_host) ]
This setup is all that’s needed when you specify a path to the certificate file.
The next section will describe an alternative for cases where you can’t provide a path to the certificate file — which was the scenario we faced.
For our specific use case, going with the :cacertfile
route was more cumbersome than fetching the contents of the root certificate from an environment variable as a string.
Luckily for us, in addition to the :cacertfile
option, there’s also :cacerts
, which expects a list of DER-encoded trusted certificates.
This can be confusing, since :cacertfile
expects a PEM-encoded file, whereas :cacerts
expects DER-encoded data.
For the sake of this post, we can very broadly simplify the difference as PEM being a text encoding containing Base64- and ASCII-encoded items, while DER is a binary encoding format. A recommended deeper explanation is available in this article: PEM, DER, CRT, and CER: X.509 Encodings and Conversions.
In our case, our string is PEM encoded, so before being able to feed the certificate into the :cacerts
option, we need to obtain a DER-encoded representation of the certificate(s).
For this, we can rely on the public_key
module from Erlang — specifically, the pem_decode/1
function, which returns a list of {{pki_asn1_type(), der_or_encrypted_der(), not_encrypted | cipher_info()}}
tuples.
We need to take the second argument of each tuple, which is the DER-encoded representation of the certificate:
der_certs = db_cacerts |> :public_key.pem_decode() |> Enum.map(fn {_, der, _} -> der end)
With this, our SSL configuration could look like the following:
db_host = "example.com" config :my_app, MyApp.Repo, ssl: true, ssl_opts: [ cacerts: decoded_certs, verify: :verify_peer, server_name_indication: to_charlist(db_host) ]
ℹ️ Note: Refer to the
tls_client_option
docs from the Erlangssl
module for a full list of possible options for:ssl_opts
.
Conclusion
And that’s it! The final solution for feeding a root certificate from a string when establishing database connections to PostgreSQL with verification is really simple after all, but it was tricky to obtain the exact steps since we didn’t find them all in a single place.
I hope this post can help people encountering similar situations when establishing secure connections to their databases. Thanks for reading!