TLS (Transport Layer Security) support

TLS support was introduced in nREPL 1.1.

Historically, the standard way to securely access a remote nREPL server has been to use ssh with port forwarding, and letting the nREPL server only bind to 127.0.0.1. This will secure and encrypt the communication.

Sometimes however ssh may not be available, and thus you need to bind to 0.0.0.0 (or some specific public address) to make the nREPL server available.

By using TLS (Transport Layer Security) the successor to SSL (Secure Sockets Layer), we can open a secure connection directly on the nREPL server. The server will require each client to authenticate itself.

The following sections will show how to generate the certificates and keys required for running a TLS nREPL server and client.

Generating certificates and keys

First things first - we need to generate the some certificates and keys. One simple option is to use the Clojure library locksmith:

$ clojure -Sdeps '{:aliases {:write-cert {:deps {com.github.ivarref/locksmith {:mvn/version "0.1.6"}} :exec-fn com.github.ivarref.locksmith/write-certs!}}}' -T:write-cert :duration-days 365
Wrote server.keys
Wrote client.keys

This will generate two 256-bit ECDSA keys. Both files contain the root certificate. Each file also contains the server/client’s certificate and private key. The root certificate key is thrown away.

These two files are all you need to run and connect to a TLS nREPL server.

It’s also possible to generate certificates and keys using OpenSSL:

$ openssl version
OpenSSL 1.1.1q  5 Jul 2022

$ cat ./generate.sh
#!/usr/bin/env bash

set -ex

DAYS="365"
# openssl ecparam -list_curves
CURVE="prime256v1"

# Generate root key and certificate
openssl ecparam -name "$CURVE" -genkey -noout -out ca-key.pem
openssl req -new -x509 -nodes -days "$DAYS" \
        -subj "/C=NO/ST=Hordaland/L=Bergen/O=Sikt/CN=root" \
        -key ca-key.pem -out ca-cert.pem

# Generate server key and certificate request
openssl req -newkey ec -pkeyopt ec_paramgen_curve:"$CURVE" -nodes -days "$DAYS" \
   -keyout server-key.pem \
   -subj "/C=NO/ST=Hordaland/L=Bergen/O=Sikt/CN=server" -out server-req.pem

# Generate server certificate
openssl x509 -req -days "$DAYS" -set_serial 01 \
   -in server-req.pem \
   -out server-cert.pem \
   -CA ca-cert.pem \
   -CAkey ca-key.pem

# Client key and cert request
openssl req -newkey ec -pkeyopt ec_paramgen_curve:"$CURVE" -nodes -days "$DAYS" \
   -keyout client-key.pem \
   -subj "/C=NO/ST=Hordaland/L=Bergen/O=Sikt/CN=client" -out client-req.pem

# Client cert
openssl x509 -req -days "$DAYS" -set_serial 01 \
   -in client-req.pem \
   -out client-cert.pem \
   -CA ca-cert.pem \
   -CAkey ca-key.pem

# Write files in format of nREPL:
rm -fv ./client.keys ./server.keys
cat ca-cert.pem server-cert.pem server-key.pem > server.keys
cat ca-cert.pem client-cert.pem client-key.pem > client.keys
chmod 0400 server.keys client.keys

# Clean up:
rm ca-key.pem ca-cert.pem \
   server-key.pem server-req.pem server-cert.pem \
   client-key.pem client-req.pem client-cert.pem

This will produce roughly the equivalent key files as the clj command above.

The openssl curve parameters do not exactly match the ones used by the clj command. That is why the private key generated by these openssl seems slightly longer.

Starting the nREPL server

Here’s how you can start nREPL programmatically with TLS enabled:

=> (require '[nrepl.server :refer [start-server stop-server]])
nil
=> (defonce server (start-server :bind "0.0.0.0"
                                 :port 4001
                                 :tls? true ; will cause the server to fail if no certs/keys are given
                                 ; if server.keys is on the server
                                 :tls-keys-file "server.keys"))
                                 ; use :tls-keys-str if the contents of server.keys is available as a string,
                                 ; e.g.
                                 ; :tls-keys-str (System/getenv "NREPL_SERVER_KEYS")
='user/server

Connect with cmdline client

$ clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.1.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 4001 --tls-keys-file client.keys

Use the TLS TCP proxy

If your editor/IDE does not support nREPL TLS connections yet, you may start a local proxy server:

$ clojure -Sdeps '{:aliases {:proxy {:deps {nrepl/nrepl {:mvn/version "1.1.0"}} :exec-fn nrepl.tls-client-proxy/start-tls-proxy :exec-args {:remote-host "localhost" :remote-port 4001 :tls-keys-file "client.keys"}}}}' -T:proxy
INFO Started TLS proxy at 127.0.0.1:44063. Proxying to localhost:4001.

This will write the locally opened port to the file .nrepl-tls-proxy-port.

Then you may connect to localhost:<.nrepl-tls-proxy-port> using a plain (no TLS) TCP connection.

Third-party clients integration

Depending on how the socket is established, there are different approaches for using TLS as a client.

If you are using nrepl.core/connect, you will need to pass either :tls-keys-file or :tls-keys-str to that function.

If you have been using (Socket. …​) directly, you will first need to create a SSLContext using nrepl.tls/ssl-context-or-throw. Then you can create the Socket using nrepl.tls/socket:

(defn ^SSLSocket socket
  "Given an SSL context, makes a client SSLSocket."
  [^SSLContext context ^String host port connect-timeout-ms]
  ...)

Credits

This feature would not have been possible without Kyle Kingsbury’s (a.k.a "Aphyr") great work and in particular the less-awful-ssl project. Thanks!