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:

Individual client certificates can be revoked. You don’t have to configure authentication on every device you own if someone loses their phone. While I highly doubt there are any issues with HomeAssistant, I feel more confident in nginx and openssl. 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. 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:

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. 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

Install packages: sudo apt-get install nginx nginx-extras lua5.1 liblua5.1-dev openssl 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):

git clone https://github.com/mkottman/luacrypto /opt/luacrypto \ && cd /opt/luacrypto \ && # Fix package names for compatibility with Ubuntu echo 'diff --git a/configure.ac b/configure.ac index b6b9175..20ea20c 100644 --- a/configure.ac +++ b/configure.ac @@ -28,10 +28,10 @@ AC_CHECK_FUNCS([memset]) # pkgconfig PKG_CHECK_MODULES([OPENSSL], [openssl]) -PKG_CHECK_MODULES([LUA], [lua]) +PKG_CHECK_MODULES([LUA], [lua5.1]) # lua libdir -LUALIBDIR="`$PKGCONFIG --variable=libdir lua`" +LUALIBDIR="`$PKGCONFIG --variable=libdir lua5.1`" # dest of headers CRYPTOINC="${includedir}/${PACKAGE_NAME}"' \ | git apply \ && autoreconf -i \ && ./configure \ && make \ && sudo mkdir -p /usr/local/lib/lua/5.1 \ && sudo cp src/.libs/crypto.so /usr/local/lib/lua/5.1/crypto.so 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 git clone https : / / github .com / mkottman / luacrypto / opt / luacrypto \ && cd / opt / luacrypto \ && # Fix package names for compatibility with Ubuntu echo ' diff -- git a / configure .ac b / configure .ac index b6b9175 . . 20ea20c 100644 -- - a / configure .ac ++ + b / configure .ac @ @ - 28 , 10 + 28 , 10 @ @ AC_CHECK_FUNCS ( [ memset ] ) # pkgconfig PKG_CHECK_MODULES ( [ OPENSSL ] , [ openssl ] ) - PKG_CHECK_MODULES ( [ LUA ] , [ lua ] ) + PKG_CHECK_MODULES ( [ LUA ] , [ lua5 . 1 ] ) # lua libdir - LUALIBDIR = "`$PKGCONFIG --variable=libdir lua`" + LUALIBDIR = "`$PKGCONFIG --variable=libdir lua5.1`" # dest of headers CRYPTOINC = "${includedir}/${PACKAGE_NAME}" ' \ | git apply \ && autoreconf - i \ && . / configure \ && make \ && sudo mkdir - p / usr / local / lib / lua / 5.1 \ && sudo cp src / .libs / crypto .so / usr / local / lib / lua / 5.1 / crypto .so

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:

$ cat `which create-client-ssl-cert` #!/bin/bash function usage () { echo "$0 [CA section name] [username]" exit 1 } if [ $# -ne 2 ] then usage fi CA_NAME="$1" USERNAME="$2" SSL_DIR="/etc/ssl" SSL_PRIVATE_DIR="$SSL_DIR/${CA_NAME}/private" SSL_CERTS_DIR="$SSL_DIR/${CA_NAME}/certs" USERS_DIR="${SSL_CERTS_DIR}/users" mkdir -p ${USERS_DIR} if [ -f "${USERS_DIR}/${USERNAME}.key" ]; then echo "Key for $USERNAME already exists! Delete it to continue." exit 1 fi # Create the Client Key and CSR openssl genrsa -des3 -out ${USERS_DIR}/${USERNAME}.key 1024 openssl req -new -key ${USERS_DIR}/${USERNAME}.key -out ${USERS_DIR}/${USERNAME}.csr # Sign the client certificate with our CA cert. Unlike signing our own server cert, this is what we want to do. openssl x509 -req -days 1095 -in ${USERS_DIR}/${USERNAME}.csr -CA $SSL_CERTS_DIR/ca.crt -CAkey $SSL_PRIVATE_DIR/ca.key -CAserial $SSL_DIR/${CA_NAME}/serial -CAcreateserial -out ${USERS_DIR}/${USERNAME}.crt echo "making p12 file" #browsers need P12s (contain key and cert) openssl pkcs12 -export -clcerts -in ${USERS_DIR}/${USERNAME}.crt -inkey ${USERS_DIR}/${USERNAME}.key -out ${USERS_DIR}/${USERNAME}.p12 echo "made ${USERS_DIR}/${USERNAME}.p12" 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 $ cat ` which create - client - ssl - cert ` #!/bin/bash function usage ( ) { echo "$0 [CA section name] [username]" exit 1 } if [ $ # -ne 2 ] then usage fi CA_NAME = "$1" USERNAME = "$2" SSL_DIR = "/etc/ssl" SSL_PRIVATE_DIR = "$SSL_DIR/${CA_NAME}/private" SSL_CERTS_DIR = "$SSL_DIR/${CA_NAME}/certs" USERS_DIR = "${SSL_CERTS_DIR}/users" mkdir - p $ { USERS_DIR } if [ - f "${USERS_DIR}/${USERNAME}.key" ] ; then echo "Key for $USERNAME already exists! Delete it to continue." exit 1 fi # Create the Client Key and CSR openssl genrsa - des3 - out $ { USERS_DIR } / $ { USERNAME } .key 1024 openssl req - new - key $ { USERS_DIR } / $ { USERNAME } .key - out $ { USERS_DIR } / $ { USERNAME } .csr # Sign the client certificate with our CA cert. Unlike signing our own server cert, this is what we want to do. openssl x509 - req - days 1095 - in $ { USERS_DIR } / $ { USERNAME } .csr - CA $SSL_CERTS_DIR / ca .crt - CAkey $SSL_PRIVATE_DIR / ca .key - CAserial $SSL_DIR / $ { CA_NAME } / serial - CAcreateserial - out $ { USERS_DIR } / $ { USERNAME } .crt echo "making p12 file" #browsers need P12s (contain key and cert) openssl pkcs12 - export - clcerts - in $ { USERS_DIR } / $ { USERNAME } .crt - inkey $ { USERS_DIR } / $ { USERNAME } .key - out $ { USERS_DIR } / $ { USERNAME } .p12 echo "made ${USERS_DIR}/${USERNAME}.p12"

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

# create-client-ssl-cert ca chris_pixel 1 # create-client-ssl-cert ca chris_pixel

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:

map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream hass_backend { server 127.0.0.1:8123; } server { listen 80; listen 81; server_name my-homeassistant-install.mydomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name my-homeassistant-install.mydomain.com; error_log /var/log/nginx/my-homeassistant-install.mydomain.com/error.log; access_log /var/log/nginx/my-homeassistant-install.mydomain.com/access.log; ssl_certificate /etc/letsencrypt/live/my-homeassistant-install.mydomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/my-homeassistant-install.mydomain.com/privkey.pem; ssl_dhparam /etc/nginx/ssl/dhparams.pem; ssl on; add_header Strict-Transport-Security "max-age=31536000; includeSubdomains"; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_client_certificate /etc/ssl/ca/certs/ca.crt; ssl_crl /etc/ssl/ca/private/ca.crl; ssl_verify_client optional; proxy_buffering off; proxy_redirect http:// https://; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; location / { #access_by_lua_file /etc/nginx/scripts/hass_access.lua; proxy_pass http://hass_backend; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 map $ http_upgrade $ connection_upgrade { default upgrade ; '' close ; } upstream hass_backend { server 127.0.0.1 : 8123 ; } server { listen 80 ; listen 81 ; server_name my - homeassistant - install . mydomain . com ; return 301 https : //$host$request_uri; } server { listen 443 ssl ; server_name my - homeassistant - install . mydomain . com ; error_log / var / log / nginx / my - homeassistant - install . mydomain . com / error . log ; access_log / var / log / nginx / my - homeassistant - install . mydomain . com / access . log ; ssl_certificate / etc / letsencrypt / live / my - homeassistant - install . mydomain . com / fullchain . pem ; ssl_certificate_key / etc / letsencrypt / live / my - homeassistant - install . mydomain . com / privkey . pem ; ssl_dhparam / etc / nginx / ssl / dhparams . pem ; ssl on ; add_header Strict - Transport - Security "max-age=31536000; includeSubdomains" ; ssl_protocols TLSv1 TLSv1 . 1 TLSv1 . 2 ; ssl _ ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4" ; ssl_prefer_server_ciphers on ; ssl_session_cache shared : SSL : 10m ; ssl_client_certificate / etc / ssl / ca / certs / ca . crt ; ssl_crl / etc / ssl / ca / private / ca . crl ; ssl_verify_client optional ; proxy_buffering off ; proxy_redirect http : // https://; proxy_set _ header X - Real - IP $ remote_addr ; proxy_set _ header X - Forwarded - For $ proxy_add_x_forwarded_for ; proxy_set _ header X - Forwarded - Proto $ scheme ; proxy_http _ version 1.1 ; proxy_set_header Upgrade $ http_upgrade ; proxy_set_header Connection $ connection_upgrade ; proxy_set_header Host $ host ; location / { #access_by_lua_file /etc/nginx/scripts/hass_access.lua; proxy_pass http : //hass_backend; } }

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:

[Error] WebSocket connection to 'wss://my-homeassistant-install.mydomain.com/api/websocket' failed: Unexpected response code: 400 (x11) 1 [ Error ] WebSocket connection to 'wss://my-homeassistant-install.mydomain.com/api/websocket' failed : Unexpected response code : 400 ( x11 )

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:

access_by_lua_file /etc/nginx/scripts/hass_access.lua; 1 access_by_lua_file / etc / nginx / scripts / hass_access . lua ;

And add the script:

local HMAC_SECRET = "hunter2" local crypto = require "crypto" function ComputeHmac(msg, expires) return crypto.hmac.digest("sha256", string.format("%s%d", msg, expires), HMAC_SECRET) end verify_status = ngx.var.ssl_client_verify if verify_status == "SUCCESS" then client = crypto.digest("sha256", ngx.var.ssl_client_cert) expires = ngx.time() + 3600 ngx.header["Set-Cookie"] = { string.format("AccessToken=%s; path=/", ComputeHmac(client, expires)), string.format("ClientId=%s; path=/", client), string.format("AccessExpires=%d; path=/", expires) } return elseif verify_status == "NONE" then client = ngx.var.cookie_ClientId client_hmac = ngx.var.cookie_AccessToken access_expires = ngx.var.cookie_AccessExpires if client ~= nil and client_hmac ~= nil and access_expires ~= nil then hmac = ComputeHmac(client, access_expires) if hmac ~= "" and hmac == client_hmac and tonumber(access_expires) > ngx.time() then return end end end ngx.exit(ngx.HTTP_FORBIDDEN) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 local HMAC_SECRET = "hunter2" local crypto = require "crypto" function ComputeHmac ( msg , expires ) return crypto . hmac . digest ( "sha256" , string . format ( "%s%d" , msg , expires ) , HMAC_SECRET ) end verify_status = ngx . var . ssl_client_verify if verify_status == "SUCCESS" then client = crypto . digest ( "sha256" , ngx . var . ssl_client_cert ) expires = ngx . time ( ) + 3600 ngx . header [ "Set-Cookie" ] = { string . format ( "AccessToken=%s; path=/" , ComputeHmac ( client , expires ) ) , string . format ( "ClientId=%s; path=/" , client ) , string . format ( "AccessExpires=%d; path=/" , expires ) } return elseif verify_status == "NONE" then client = ngx . var . cookie_ClientId client_hmac = ngx . var . cookie_AccessToken access_expires = ngx . var . cookie_AccessExpires if client ~ = nil and client_hmac ~ = nil and access_expires ~ = nil then hmac = ComputeHmac ( client , access_expires ) if hmac ~ = "" and hmac == client_hmac and tonumber ( access_expires ) > ngx . time ( ) then return end end end ngx . exit ( ngx . HTTP_FORBIDDEN )

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.

