Securing HomeAssistant with client certificates (works with Safari/iOS)

I recently moved from SmartThings to HomeAssistant. One of the things I didn’t have to think about too much with SmartThings was how to authenticate all of my connected devices (laptops, phones, tablets, etc.) with my HA platform. I wanted to find a good balance between security and convenience with HomeAssistant.

HomeAssistant makes it easy to secure your install with a password. Coupled with TLS, this is pretty solid. But there’s just something about the idea of a publically facing page that anyone on the Internet can get to, protected with nothing but a password that made me feel uneasy.

Client certificates are a very robust authentication mechanism that involves installing a digital certificate on each device you wish to grant access to. Each certificate is signed by the server certificate, which is how the server knows that the client is valid.

This feels nicer than HomeAssistant’s built-in security measures to me for a few reasons:

  1. Individual client certificates can be revoked. You don’t have to configure authentication on every device you own if someone loses their phone.
  2. While I highly doubt there are any issues with HomeAssistant, I feel more confident in nginx and openssl.
  3. Unless you add a passphrase to the client certificates (I didn’t), the whole thing is passwordless and still manages to be pretty darn secure.
  4. If I ever became truly paranoid, I could turn on HomeAssistant’s password protection and my HA dashboard would essentially need two authentication factors (the SSL cert + the password).

While I did find this approach more appealing, there are several drawbacks:

  1. It’s way harder to set up. You need to run a bunch of openssl commands, and install a certificate on each device you want to grant access to.
  2. The HomeAssistant web UI requires WebSockets, which seem to not play nicely in combination with client certificates on Safari or iOS devices. My household has iOS users, so this was something I needed to figure out.

I think I managed to get this working. The only disadvantage is that clients are granted access for an hour after successfully authenticating once. The basic approach is to tag authenticated browsers with an access token that’s good for a short period of time, long enough for them to establish a WebSocket connection. I’ll go through the steps in setting this up.

What you need

  1. Install packages:  sudo apt-get install nginx nginx-extras lua5.1 liblua5.1-dev
  2. openssl
  3. luacrypto module, which exposes openssl bindings in lua.

luacrypto was kind of a pain to install. Here’s what I did to get it working with my nginx install. It involved patching configure.ac (thanks to this very helpful StackOverflow post for the tip):

Setting up a Certificate Authority

There are already good guides on doing this. I recommend this one. In this guide, I’m using the default_CA parameters pre-filled by openssl on my system.

Generate client certificates

I put a script in /usr/local/bin  to make this easier:

You then run this for each device you want to grant access to:

Make sure to supply an export password. Generated certificate files will be placed in /etc/ssl/ca/certs/users .

Get the certificates on the devices

The .p12 file is the one you want. Make sure to not compromise the certificates in the process.

I rsynced the files to my laptop and attached them to a LastPass note, which I could access on my devices. On most devices, you should be able to just open the .p12 file and it’ll do what you want.

On iOS devices, I needed to serve the certificates over HTTPS on a trusted network because they needed to be “opened” by Safari in order to be recognized.

Configure nginx

Here’s my nginx config. You’ll need to substitute your domain and SSL certificate parameters:

If this all worked, you should be able to access my-homeassistant-install.mydomain.com from a device with a client certificate installed, but not otherwise. Unfortunately if you’re using iOS or Safari, you’ll probably notice that the page loads, but you get a forever spinny wheel. If you look in the debugger console, you might see messages that look like this:

This is because the browser isn’t sending the client certificate information when trying to connect to the WebSocket and is therefore failing.

Fixing compatibility with iOS/Safari

Safari does actually send client cert info along with the initial request. Nginx has a really cool module that allows you to insert all sorts of fancy logic with lua scripts. I added one that tags browsers supplying a valid client certificate with a cookie granting access for about an hour. This worked really well. Since this is all over HTTPS, and the access tokens are short-lived, I felt pretty comfortable.

The easiest way I could think of to create a cookie that was valid for a limited time was to use an HMAC. Basically I “sign” a hash of the client’s certificate along with an expiry timestamp. The certificate hash, expiration timestamp, and HMAC are all stored in cookies. Nginx can then validate that the expiration timestamp is in the future, and that the HMAC signature matches what’s expected.

You’ll notice the commented-out line in the nginx config above. Uncomment it:

And add the script:

Update (August, 2019)

After upgrading to Ubuntu 18.04, the nginx LUA module stopped working, and every combination of fixes I tried didn’t work.

Fortunately, OpenResty worked out of the box. After adding their PPA and installing it, all of my existing scripts worked. I just copied over my entire nginx config.

Join the Conversation

31 Comments

Your email address will not be published. Required fields are marked *

 

This site uses Akismet to reduce spam. Learn how your comment data is processed.

  1. I’ d like to be able to both reach the default hass webinterface securely via client certificates, and use the Google Assistant plugin.

    Would it be possible to tweak nginx to allow for this? Or any other method?

    Thanks a lot for your advice.

     

    1. I’m not familiar with the Google Assistant plugin. If it provides some form of auth, it’s possible to mess around with the LUA to detect either that or the client cert.

  2. I’ve tried a ton of different things to get luacrypto running, even tried other openssl packages, etc. This was the only way I got it working with lua 5.1..:

    git clone https://github.com/evanlabs/luacrypto.git
    luarocks install rockspecs/luacrypto-git-1.rockspec
    ln -s /usr/local/lib/lua/crypto.so /usr/local/lib/lua/5.1

    1. Did the trick I mentioned in the post with patching configure.ac not work for you? Sort of sounds like what you’re suggesting would have roughly the same effect.

    2. The code above is a bit of a mess 😉 I’ve changed both lua entries in the configure.ac to lua5.1
      So far so good, right?

      After that I did the autoreconf, stopping with this error:
      /usr/share/automake-1.15/am/ltlibrary.am: warning: ‘crypto.la’: linking libtool libraries using a non-POSIX
      /usr/share/automake-1.15/am/ltlibrary.am: archiver requires ‘AM_PROG_AR’ in ‘configure.ac’

      So I’ve added this line to configure.ac before LT_INIT:
      m4_ifdef([AM_PROG_AR], [AM_PROG_AR])

      Everything seemed to work fine then. Could run configure but make fails.
      lcrypto.c:1602:20: error: storage size of ‘ctx’ isn’t known
      Error 2 and so on.

  3. With the broken pkcs12 conversion fixed as described in https://blog.christophermullins.com/2017/04/30/securing-homeassistant-with-client-certificates/comment-page-1/#comment-4778, I can now successfully make the initial connection to HA using Safari on both iOS and macOS, BUT they both stop at loading the main UI with the Safari web inspector reporting: WebSocket connection to 'wss:///api/websocket?latest' failed: Unexpected response code: 403. Seems the Safari/WebKit bug still hasn’t had any love: https://bugs.webkit.org/show_bug.cgi?id=158345

    I could work around this using the lua script, but I noticed the HA iOS app doesn’t even show the HA loading UI, just the ngninx 403 forbidden page, so apparently it doesn’t even send the client cert for the normal HTTP requests 🙁

    Did anyone solve the latter part?

    1. Yeah, the entire point of this post is the Lua workaround. Websockets in both Chrome and Safari just do not play nicely with client certificates.

      I don’t think the iOS app supports using client certificates, but I don’t use the iOS app. Just use a homepage link which works fine for my purposes.

    2. Actually Websockets in Chrome (68.0.3440.42 on macOS) work fine without the Lua workaround, but yes, it’s needed for Safari (and Chrome on iOS, which is just wrapping the WebKit engine and networking backend, so it has the same issues as Safari).

      The advantage of the iOS app is that it supports location updates for presence detection. According to this tech note (https://developer.apple.com/library/archive/qa/qa1745/_index.html) the UIWebView used by the iOS app does not inherit the keys added to the Safari keychain, but it should be possible to add the client certs to the HA iOS app itself. I’ll have a look at implementing that.

    3. Er, yeah. Sorry. I guess I probably meant service worker. Or some other similar frontend thing. There’s definitely something in either Chrome or HomeAssistant that does not work well with client certs for long periods of time.

      I actually sort of gave up on using client certs for every request. Doubled down on the HMAC cookies, and now redirect to a domain that drops authed cookies based on client certs when cookies are not present.

      Works absolutely beautifully — exactly how I wanted.

      This makes sense, I would want to use the native app if I were using this feature as well.

  4. Any tips on how to get the client certificate working on macOS and iOS? I’ve installed the PKCS #12 file in the Keychain on macOS, and onto the iOS device, but Safari doesn’t seem to pass the certificate on, even with ssl_verify_client on;. Nor does Chrome on macOS, probably because it uses the Keychain certs as well. In Firefox there’s a separate preference to add “personal certificates”, and after adding the p12 file there, things work fine in Firefox. I can also load the page with curl from the macOS machine if I pass the key, cert, and cacert arguments.

    Do you need to do something extra after importing the p12 onto the iOS device? Does the CA root certificate also need to be added, or trusted? (I thought that was part of the p12 file).

    Does the name, organisation, city, etc, in the certs (CA or client, or both) need to match the server cert (from LetsEncrypt in my case) or something along those lines?

    1. Aparently, the command in your script:

      openssl pkcs12 -export -clcerts -in ${USERS_DIR}/${USERNAME}.crt -inkey ${USERS_DIR}/${USERNAME}.key -out ${USERS_DIR

      and the one in e.g. https://gist.github.com/mtigas/952344:

      openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12

      does not match exactly the one I used, from https://fardog.io/blog/2017/12/30/client-side-certificate-authentication-with-nginx/:

      openssl pkcs12 -export -out user.pfx -inkey user.key -in user.crt -certfile ca.crt

      The certfile argument seems to mess things up, possibly because the ca.crt isn’t installed on iOS/macOS?

    2. I use this trick on OSX, Android, and iOS. Works really well for me.

      One of the fields you specify when generating the certificate has to match the domain you hit to access HomeAssistant (or whatever you’re securing). I think it’s probably the “Organization” field of the Subject Name. This supplies a hint to the OS that this certificate should be used when a client cert is requested by the server.

      In OS X there’s a concept of an “Identity Preference,” which you can set up in “Keychain Access.” (File > New Identity Preference…). I don’t think this is necessary if the certificate is configured correctly, but it might be a workaround if you don’t want to regenerate things.

  5. You’re setting ssl_verify_client optional;, but not verifying the $ssl_client_verify variable later on. From my testing and reading the docs, this means that if the browser doesn’t send a certificate, ngnix will happily serve the page. Is there something I’m missing? Shouldn’t this be ssl_verify_client on;, or if ($ssl_client_verify != SUCCESS) {
    return 403;
    }

    ?

    1. Never mind, I just realised you’re doing the $ssl_client_verify check in the lua script 🙂

    2. Yep, the whole trick here is to not enforce that the client supply a TLS certificate on every request, and provide a fallback authentication mechanism which is populated when the client does supply a certificate.

  6. Any idea how to get this working on a Synology DS1515+? There’s no lua or even make to compile lua installed.

    1. I have no experience with that system, but this particular technique definitely requires the Lua plugin.

  7. I spent quite some time figuring out why your script wasn’t working properly. I always got errors like:

    WebSocket network error: The operation couldn’t be completed. (OSStatus error -9807.)

    The solution is quite “simple” tough.. iOS is much, much more picky about certificates and stuff than Safari on a Mac is.

    On a Mac it seems to be enough to just install and trust the (self created) Root CA, but on iOS you really need the whole chain from Root CA -> Intermediate CA -> Website. If you’re testing this locally, you have actually to create a certificate with the Common Name 10.1.1.100 or whatever your local IP for testing is! Otherwise Safari will prompt you with something like »really open this website?«, then the website loads, you think »Everything is loading, so why is the web socket not working?«.

    So, solution: Provide the whole chain of certificates via a Profile (create one with Apple Configurator 2), install it on your device and then explicitly trust the Root CA (because installing the Profile doesn’t trust the certificates yet).

    1. I didn’t need an intermediate certificate. But the CN in the cert is for the appropriate URL. I assume that’s necessary for Safari to know when it’s appropriate to use a particular certificate.

      I did need to trust the cert, though. On OS X I was able to do that in the keychain manager. On iOS opening the p12 lead to a series of prompts that resulted in the certificate being trusted, I think.

      The biggest problem I’ve run into that I’ve not exactly solved is that HomeAssistant started using ServiceWorkers about 6 months ago (I think?). It tries to download /service_worker.js every so often, and it Chrome doesn’t seem to support client certs in this context.

  8. Is the iOS app using a WebView? If so it might perhaps share client certs with Safari. Perhaps the workaround using the lua script leaves any Safari-based WebView to access HA during the one hour window?

  9. Hi Chris,

    sounds good but unfortunately seems not to work as I would expect on my side.

    I mean, setting ssl_verify_client as ‘optional’ I suspect client authentication is just skipped by nginx and in fact I am able to see HA page from iOS Safari even commenting out the lua script.

    Instead forcing ssl_verify_client to ‘on’ I cannot access to that page even with the lua script enabled.

    Any thoughts?

    Nicola

     

    1. The LUA script is what’s doing the enforcing. It enforces that either the browser provides client cert details, or that a valid auth cookie is in place. So having ssl_verify_client to optional while commenting out the LUA script should have the behavior you’re seeing.

      Having it set to “on” will force every request to provide client cert details, which is exactly what the LUA script is trying to get around.

    2. Got it,
      performing some further testing I confirm that it works as you are describing.

      Thank for sharing all this info and the script code!

    1. I’m not really familiar with the native iOS app. It’s not impossible that it works, but I sort of doubt it does.

    2. So surprisingly this worked out of the box in the iOS app for me. In the recent iOS11 beta I’ve been having some issues, but I’m not sure if that’s specific to me upgrading the OS or the version of Caddy that I use (a replacement for nginx).

    3. You’re saying that client certs out of the box works with the HASS iOS app? The issue I observed was that Safari (OS X and iOS versions) didn’t send client cert details when making web socket requests, which I think would only be fixed by a software update on Apple’s side.

  10. This was super-informative, and exactly what I was looking to do! Thanks for the great write up and step-by-step instructions. The worst part was luacrypto, but your patch even made that a breeze. Thanks again!