Any way to use HTTP::Server with SNI?

I’d really like to be able to recognize multiple certificates via SNI (Server Name Indication), and send back the appropriate one. There’s nothing like that in the Crystal code for HTTPS::Server, but I think maybe it might be done at the openssl level? This is one of the last things I need to tackle before I can drop nginx completely off of my home server.

If you use the OpenSSL::SSL::Context#ca_certificates_path property, does it not pick the correct cert when using SNI on the client side?

I will have to try this. Would there be a way to ensure that the received Host: header matches what the client passed for SNI? Or would it be up to me to ensure it’s one of the valid Host headers matched by the certificates?

This obviously wouldn’t happen with a well-behaved client–it’ll always pass the same Host: header value as it used for SNI. But not all clients are well-behaved. When testing against a development instance of the server, it might not be in DNS, so you might need to send specific values for the SNI and Host: header, and could accidentally mismatch them. And a bad actor could pass a value that results in unintended code paths—this is a common method to get to unintended code paths in a server.

Before I answer, I just realized that I completely misunderstood what you were trying to do. The method I suggested is for CAs to trust, not the certs to use for TLS sessions. It looks like more OpenSSL APIs need to be exposed in Crystal to support what you were asking for originally.

FWIW I was thinking about something similar recently (I was going to play around with implementing a Kubernetes ingress controller in Crystal) but this exact thing was what blocked me from doing it.


You’d need to check it manually. It can be done in middleware, though, so you can implement it once and use it in multiple apps.

There is an OpenSSL::SSL::Socket#hostname method, but the HTTP::Server::Context doesn’t provide a way to get that, so it involves a bit of monkeypatching.

Script to generate certs for testing
#!/usr/bin/env bash

set -euo pipefail

echo <<EOF
Note: You must provide a passkey. I don't know why, but it
seems to be required.
EOF

# Generate the CA signing key
openssl genrsa -aes256 -out ca.key 4096

# Generate the CA cert
openssl req -x509 -new -nodes -key ca.key -sha256 -days 1826 -out ca.crt

# Generate the CSR (certificate signing request)
openssl req -new -nodes -out web.csr -newkey rsa:4096 -keyout web.key -subj '/CN=My Firewall/C=AT/ST=Vienna/L=Vienna/O=MyOrg'

cat > web.v3.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF

openssl x509 -req -in web.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out web.crt -days 730 -sha256 -extfile web.v3.ext
require "http"
require "openssl"

tls = OpenSSL::SSL::Context::Server.new
# Set this to the right files
tls.certificate_chain = "web.crt"
tls.private_key = "web.key"

http = HTTP::Server.new([
  SNIValidationMiddleware.new,
  App.new,
])

http.bind_tls "127.0.0.1", 8080, tls
http.listen

class SNIValidationMiddleware
  include HTTP::Handler

  def call(context)
    sni_hostname = context.response.openssl_socket.hostname

    if sni_hostname == context.request.hostname
      call_next context
    else
      context.response.status = :bad_request
    end
  end
end

class App
  include HTTP::Handler

  def call(context)
    context.response.puts "Yep"
  end
end

class HTTP::Server::Response
  # Needed to be able to get the OpenSSL context
  def openssl_socket
    @io.as(OpenSSL::SSL::Socket)
  end
end

I think I understand this.

Can I add multiple private server keys to the openSSL context, and do SNI among them? Or does it have to be a single cert? The former is what I’m hoping for.

Does the OpenSSL library expose what I’d need to read the subject and alt names from each private key I’m using? I can pass them out-of-band, or spawn the openssl CLI to extract them at the time when I read them, but it’d be nice to do it more “neatly” than that.

I’ll have to play with it. Thanks for your help!

It’s the latter, unfortunately. That’s what I meant about more of the OpenSSL APIs needing to be exposed in Crystal. I’m sure the C APIs are there (presumably, that’s what nginx is using), but AFAIK there aren’t Crystal bindings for it.

You might be able to find something in spider-gazelle/openssl_ext, but I’m not sure.

Same answer here, unfortunately. The OpenSSL API surface is massive and the Crystal stdlib only supports a small subset out of the box with openssl_ext adding support for a handful of others.

I would love for the Crystal stdlib openssl to support more of OpenSSL, but the effort required would be substantial.

I am but a young grasshopper, and OpenSSL is not a place for young grasshoppers to tread: I wouldn’t be comfortable sharing any work on this, since I could be accidentally opening Pandora’s box. OpenSSL is not a friendly thing.

I think I’ll have to continue to use nginx as a front for my server for now. It’s not important, just something to run on my little home server. Hopefully as Crystal is adopted more and more, it’ll eventually be addressed. As we get “real” multi-threading and more mature concurrency approaches in place, I suspect a Crystal web server/router/reverse proxy would be capable of competing very well on performance to learning curve ratio. And someday when we can get the compile time reduced for larger projects (a very difficult task, and not a high priority), it’d open the door to really shine in high-performance scenarios.