Search code examples
javascalasslhttpsakka-http

How to setup proper SSL between 2 Scala/Akka web services?


Background:

I have a Scala (with AKKA-http) webserver, that is able to talk to instances of itself (de-centralised model). I recently upgraded the server to use SSL and https.

I have run into a problem with the communications between two servers, related to SSL certificates:

java.lang.RuntimeException: javax.net.ssl.SSLHandshakeException: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

Thoughts:

A quick search for the exception lead me to this post. I had seen the keytool command at various points when I was researching SSL in Scala/Java so I know I'm on the right track.

However, the service I am writing needs to be end-user friendly. The answers in this post require a lot of user setup. I would like the user to have to do as little as possible in the way of setting this up and would like to have my server handle this itself.

The model here was you share your domain name and serverID with your friends, and they can register with you. The post mentioned would require you to also share your SSL certificate and manually add it on your machine for each server you register with.

I think I need to exchange the SSL certificate at some point during the registration handshake?

Handshake Process:

Currently the handshake process is as follows:

  1. User A create a RegistrationRequest(serverID, serverIP, alias) to User B, and a database entry for User B
  2. User B receives the request, creates an entry in its database for User A
  3. User B (the actual end user) clicks "Accept" on the front-end for User A's request
  4. User B send a RegistrationAccept message to user A
  5. User A receives the accept request, and updates the database to reflect it is accepted
  6. The chatter service is started on both ends

I was thinking I would do the handshake with http, inserting the SSL certificate into the RegistrationRequest object, and upgrade the connection to https when the certificate was exchanged, but this is a problem. Each webserver binds to port 8080. I would have to re-bind the entire socket each time which will work for just 1 to 1 peers, but not 1 to many.

Any suggestions on the correct approach to solving this?

Thanks in advance!

UPDATE:

I have come quite far with this project and integrated a lot of features into the backend. However, the system is meant to be security focused so if I continue this way, SSL is a must.

I didn't really want to re-write the entire backend, but assuming I did, are websockets what I want here instead of my current approach?

UPDATE 2:

Seems there are two approaches, neither of which I'm completely sure about (from a security perspective).

Approach 1: I ship the software with a single RootCA certificate, that I use to generate the SSL certificates. This seems like it might have security implications if someone gets hold of the RootCA and generates their own certificates.

Approach 2: I create 2 sockets, one for http (port 8080) and another for https (port 8443).

The http socket exposes routes ONLY for authenticating credentials (and providing a token) and for adding a certificate to the truststore (ONLY if the token is valid).

Then any requests on the https routes will be rejected if the certificate is not present or the token is invalid, but will work for registered users.

Again, seems to have security implications sharing certificate over http, but seems better than shipping with RootCA as it at least has authentication wrapped around it (or am I wrong?)


Solution

  • I think I have formed a plan.

    I bind a connection using HTTP to 8080, and allow an auth route and a certificate route (protected by auth token).

    Each instance encrpyts the certificate using the hashed password

    The hashed password is used on the auth root to get an access_token

    The instnace can then use this access_token to send its encrypted certificate to the other server, which gets put into the other servers truststore, allowing https to be used going forward.

    I think this solves the concerns with a) shipping the same RootCA and b) sending unencrypted certificates via http.

    I will implement this over the next couple of days. If someone spots any security concerns I have potentially missed with this, please comment otherwise I will mark it as the answer when I have tested it works.

    UPDATE:

    I have fully implemented this solution, and seems to work pretty well. When a server instance is first aware of another (through user input), it asks for an authToken via http, which it then uses to send it's encrypted certificate over http to the other instance. The other instance responds with its encrypted certificate. Each side decrypts and stores the certificate in the truststore, allowing for full https communications both ways.

    The section in my SSLManager objet for registering and encrypting/decrypting certificates:

      def registerEncryptedCertificate(certificateResponse: CertificateResponse)(implicit actorSystem: akka.actor.ActorSystem): Boolean = {
        var success = false
    
        try {
          val decryptedCertificateData = decrypt(Base64.getDecoder.decode(certificateResponse.encryptedCertificate), DatabaseUtil.hashString(PASSWORD))
          val certificateString = String(decryptedCertificateData, "UTF-8")
    
          // write the certificate to file if it doesn't exist
          if (!fileExists(SSL_ROOT + s"${certificateResponse.serverID}.crt")) {
            writeFile(SSL_ROOT + s"${certificateResponse.serverID}.crt", Seq(certificateString))
    
            // load the certificate into truststore depending on platform
            OS.getOS match {
              case MAC =>
                Seq("bash", "-c", s"keytool -import -v -trustcacerts -alias ${certificateResponse.serverID} " +
                  s"-file ${SSL_ROOT + certificateResponse.serverID}.crt " +
                  "-storepass changeit -noprompt -keystore $(/usr/libexec/java_home)/lib/security/cacerts") !!
                
                  success = true
    
              case LINUX =>
                Seq("bash", "-c", s"keytool -import -v trustcacerts -alias ${certificateResponse.serverID} " +
                  s"-file ${SSL_ROOT + certificateResponse.serverID}.crt " +
                  "-storepass changeit -noprompt -keystore $(dirname $(dirname $(readlink -f $(which javac))))/lib/security/cacerts") !!
    
                success = true
              //TODO: WINDOWS OS support
            }
          } else {
            actorSystem.log.warning(s"Certificate for serverID:${certificateResponse.serverID} already exists.")
            success = true
          }
    
        } catch {
          case e: Exception =>
            actorSystem.log.error(s"Something went wrong processing the certificateResponse:${certificateResponse.toProtoString}", e)
        }
        
        success
      }
    
      def registerEncryptedCertificate(certificateRequest: CertificateRequest)(implicit actorSystem: ActorSystem[_]): CertificateResponse = {
        var result: CertificateResponse = CertificateResponse(null, SERVER_ID, false, "Failed to register certificate")
    
        try {
          val decryptedCertificateData = decrypt(Base64.getDecoder.decode(certificateRequest.encryptedCertificate), DatabaseUtil.hashString(PASSWORD))
          val certificateString = String(decryptedCertificateData, "UTF-8")
    
          // write the certificate to file if it doesn't exist
          if (!fileExists(SSL_ROOT + s"${certificateRequest.serverID}.crt")) {
            writeFile(SSL_ROOT + s"${certificateRequest.serverID}.crt", Seq(certificateString))
    
            // load the certificate into truststore depending on platform
            OS.getOS match {
              case MAC =>
                Seq("bash", "-c", s"keytool -import -v -trustcacerts -alias ${certificateRequest.serverID} " +
                  s"-file ${SSL_ROOT + certificateRequest.serverID}.crt " +
                  "-storepass changeit -noprompt -keystore $(/usr/libexec/java_home)/lib/security/cacerts") !!
                
                val encryptedCertificateString = getEncryptedCertificate(PASSWORD)
    
                result = CertificateResponse(encryptedCertificateString, SERVER_ID, true, "Successfully registered certificate")
    
              case LINUX =>
                Seq("bash", "-c", s"keytool -import -v trustcacerts -alias ${certificateRequest.serverID} " +
                  s"-file ${SSL_ROOT + certificateRequest.serverID}.crt " +
                  "-storepass changeit -noprompt -keystore $(dirname $(dirname $(readlink -f $(which javac))))/lib/security/cacerts") !!
    
                val encryptedCertificateString = getEncryptedCertificate(PASSWORD)
    
                result = CertificateResponse(encryptedCertificateString, SERVER_ID, true, "Successfully registered certificate")
              //TODO: WINDOWS OS support
            }
          } else {
            val encryptedCertificateString = getEncryptedCertificate(PASSWORD)
            result = CertificateResponse(encryptedCertificateString, SERVER_ID, true, "Certificate already registered")
          }
    
        } catch {
          case e: Exception =>
            actorSystem.log.error(s"Something went wrong processing the certificateRequest:${certificateRequest.toProtoString}", e)
        }
    
        result
      }
    
      private def getEncryptedCertificate(password: String): String = {
        // get our encrypted certificate to return
        val certificateContents = readFile(SSL_CERTIFICATE_PATH)
        val encryptedCertificateData = encrypt(certificateContents.getBytes, DatabaseUtil.hashString(password))
        Base64.getEncoder.encodeToString(encryptedCertificateData)
      }
    
      def createCertificateRequest(password: String)(implicit actorSystem: akka.actor.ActorSystem): CertificateRequest = {
        var result: CertificateRequest = null
        try {
          val encryptedCertificate = getEncryptedCertificate(password)
    
          result = CertificateRequest(encryptedCertificate, SERVER_ID)
        } catch {
          case e: Exception =>
            actorSystem.log.error(e, "Failed to create certificate request")
        }
        result
      }
    

    And the protos used:

    syntax = "proto3";
    
    package api;
    
    message CertificateRequest {
      string encryptedCertificate = 1;
      string serverID = 2;
    }
    
    syntax = "proto3";
    
    package api;
    
    message CertificateResponse {
      string encryptedCertificate = 1;
      string serverID = 2;
      bool success = 3;
      string message = 4;
    }