CFSSL - in anger, some rage

Beim Lesen dieser Seiten könnte evt. der Eindruck entstehen, ich wüsste, was ich da tue, legte Wert auf Qualität oder würde sonst in irgendeiner Weise Ergebnisse produzieren, die man weiter verwenden könne.

Dem ist nicht so :-D

Warnung: Man sollte die Dinge, die ich hier beschreibe, eher nicht nachmachen.

Ich habe gestern meine private PKI von XCA auf CFSSL umgestellt. Und ich nutze das jetzt “in anger”, also ernsthaft - die alte CA mitsamt der Keys habe ich weggeworfen. Und ja, es war ein wenig “rage” beteiligt. Nicht, weil XCA ein schlechtes Produkt ist, ganz im Gegenteil. Das Problem war, dass es so gut funktioniert hat, dass ich eine alte Regel viel zu lange befolgt habe: Die Rede ist von “8020”, aka Paretoprinzip. Und während das für viele Bereiche stimmen mag, habe ich in den letzten Jahren das Gefühl gehabt, in der IT verbringt man irgendwann mindestens 80% seiner Zeit mit den 20%, die man nicht weg automatisiert hat.

Intermezzo: Lustigerweise hat Martin Alfke das in einem Nebensatz in seinem diesjährigen OSDC-Talk genau so gesagt.

Eigentlich[tm] wollte ich mir nur ein Wildcard-Zertifikat für meine lokale Kubernetes-Testumgebung erstellen, genauer gesagt halt für den Ingress. Dabei habe ich dann aber übersehen, dass X.509 V3 SANs voneinander durch Kommata getrennt werden müssen - und nicht nur ich, sondern auch XCA. Also stand ich dann irgendwann kurz vor elf Uhr abends nicht mit der ersehnten Webseite da, sondern mit einer Zertifikat-Warnung vom Default-Ingress-Fake-Zertifikat und einem nginx-Ingress, der sich über das Zertifikat beschwert hat. Und da wurde mir plötzlich klar: Stefan, das musst Du anders machen.

Die Idee war dann also, das CFSSL-Toolkit zu benutzen. Mein hauptsächlicher Anwendungszweck sind OpenVPN-Zertifikate, das mit den HTTPS-Sachen ist nur Beiwerk. OpenVPN kann kein OCSP, aber CRLs, also wäre es irgendwie wichtig, dass das CFSSL auch CRLs generieren kann. Dazu braucht es Persistenz, i.e. eine Datenbank. Und, na ja, wie soll ich sagen: Das ganze geht auch in “ziemlich dreckig”.

Fangen wir mal mit den einfachen Sachen an: CFSSL kann man als Service betreiben, und dank systemd ist das ziemlich stressfrei, als Vorlage mag das folgende cfssl.service-File dienen:

[Unit]
Description=Certificate Authority Server
Requires=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/bin/cfssl serve -ca /etc/cfssl/ca.pem -ca-key /etc/cfssl/ca-key.pem -config /etc/cfssl/config.json -db-config /etc/cfssl/sqlite_db.json -address 0.0.0.0
Restart=always
Restart=always
RestartSec=10

Man sieht schon, das ist alles “oddly specific”, ihr merkt schon noch, warum. Als Datenbank habe ich mich für SQLite entschieden, die Konfiguration in sqlite_db.json ist denkbar simpel:

{
  "driver": "sqlite3",
  "data_source": "/var/lib/cfssl/certdb.db"
}

Zertifikat und Key sollten glaube ich klar sein, bleibt noch die Konfiguration. Ich wollte unbedingt wieder Zwischen-CAs haben, damit ich den Schlüssel für die root-CA irgendwo hin packen und vergessen kann. Eine Zwischen-CA verwende ich dafür, End-Device-Zertifikate auszustellen, und eine weitere für Kubernetes, da muss noch eine Ebene dazwischen gehen. Zudem wollte ich sinnvolle Profile für OpenVPN. Das ganze sieht dann so aus:

{
  "signing": {
    "default": {
      "auth_key": "ca_auth",
      "usages": [
        "signing",
        "key encipherment",
        "server auth",
        "client auth"
      ],
      "expiry": "8760h"
    },
    "profiles": {
      "server_ca": {
        "auth_key": "ca_auth",
        "expiry": "43800h",
        "usages": [
          "cert sign",
          "crl sign"
        ],
        "ca_constraint": {
          "is_ca": true,
          "max_path_len": 0,
          "max_path_len_zero": true
        }
      },
      "k8s_ca": {
        "auth_key": "ca_auth",
        "expiry": "43800h",
        "usages": [
          "cert sign",
          "crl sign"
        ],
        "ca_constraint": {
          "is_ca": true,
          "max_path_len": 1
        }
      },
      "openvpn_server": {
        "auth_key": "ca_auth",
        "expiry": "8760h",
        "usages": [
          "digital signature",
          "key encipherment",
          "server auth"
        ]
      },
      "openvpn_client": {
        "auth_key": "ca_auth",
        "expiry": "8760h",
        "usages": [
          "signing",
          "client auth"
        ]
      }
    }
  },
  "auth_keys": {
    "ca_auth": {
      "type": "standard",
      "key": "0123456789ABCDEF0123456789ABCDEF"
    }
  }
}

Der abgedruckte Authentifizierungs-Key ist aus der Doku ;-)

Bleibt noch das Erstellen der SQLite-Datenbank. Dazu nimmt man aus dem Sourcecode das File 001_CreateCertificate.sql, entledigt sich der DROP TABLE-Statements am Ende und jagt das ganze nach sqlite3; das Skript dazu nannte ich create-sqlitedb.sh:

#!/bin/bash

db=/var/lib/cfssl/certdb.db
sql=/usr/lib/cfssl/001_CreateCertificates.sql

if [ ! -f $db ]; then
  sqlite3 $db < $sql
fi

Et voilà. Das ganze verpackt man dann noch schön in ein Paket - in meinem Fall für Debian. Dazu muss man eigentlich nur go installieren, den Source ziehen, kompilieren, verstehen, wie man ein Debian-Paket baut und… naja, ne. Es gibt da was, das nennt sich fpm - Jordan hat das auf einem Konferenz-Vortrag mal als “Rageware” bezeichnet, und weil ich eh schon mies drauf war (und fpm in der Vergangenheit schon benutzt hatte) dachte ich mir, “nimmste das halt her”. Zudem saß ich ja an einem Arch Linux, und da war das CFSSL-Kit schon installiert, und Binaries, die man mit go erstellt hat… und so. Vor allem “und so”:

#!/bin/bash
# THIS NEEDS FPM!
set -e

VERSION="1.3.2-1"

tmpdir=$(mktemp -d)
trap "rm -rf $tmpdir" EXIT

# director setup
mkdir -p $tmpdir{/usr/bin,/etc/cfssl,/var/lib/cfssl,/lib/systemd/system,/usr/lib/cfssl}
cp $(which cfssl) $tmpdir/usr/bin/
cp $(which cfssljson) $tmpdir/usr/bin/
cp sqlite_db.json config.json $tmpdir/etc/cfssl
cp cfssl.service $tmpdir/lib/systemd/system/
cp 001_CreateCertificates.sql $tmpdir/usr/lib/cfssl

# generate DEB
fpm -s dir -t deb -n cfssl-service -v $VERSION \
  -d sqlite3 -C $tmpdir \
  --after-install create-sqlitedb.sh \
  usr/bin etc/cfssl var/lib/cfssl usr/lib/cfssl lib/systemd/system

Man sieht, da sind nicht mal die Basics drin, sowas wie Dienst stoppen wenn man das Paket deinstalliert und so. Aber soll ich Euch was sagen? Ist mir egal! ;-)

Jetzt kann man wunderbar cfssl nutzen, um sich Zertifikate von der Gegenstelle ausstellen zu lassen, die Konfiguration dazu ist:

{
  "auth_keys": {
    "ca_key": {
      "type": "standard",
      "key": "0123456789ABCDEF0123456789ABCDEF"
    }
  },
  "signing": {
    "default": {
      "auth_remote": {
        "remote": "cfssl_server",
        "auth_key": "ca_key"
      }
    }
  },
  "remotes": {
    "cfssl_server": "remote.hostname:8888"
  }
}

Zu guter Letzt habe ich etwas Shell-Kleber drum rum gemacht - und obwohl ich alles andere als stolz darauf bin gibt’s den jetzt der Vollständigkeit halber:

#!/bin/bash


usage() {
  {
    echo "Usage: $(basename $0) [ -p <profile> ] [ -h <comma-sep SAN list> ] [ -t <csr_template> ] [ -c <config> ]<cn>";
    echo;
    echo "Valid profiles:";
    echo "* openvpn_server";
    echo "* openvpn_client";
  } 1>&2
  exit 1
}

profile=""
hostnames=""
cn=""
template=~/.cfssl/csr.json
config=~/.cfssl/remote.json

# parse params
while getopts ":p:h:t:c:" o; do
  case "$o" in
    p)
      profile=$OPTARG
      ;;
    h)
      hostnames=$OPTARG
      ;;
    t)
      template=$OPTARG
      ;;
    c)
      config=$OPTARG
      ;;
    *)
      usage
      ;;
  esac
done
shift $((OPTIND-1))

# check for cn
if [ $# -ne 1 ]; then
  usage
fi
cn=$1

# check for template and config
if [ ! -f $template ]; then
  echo "template CSR $template not found"
  exit 1
fi
if [ ! -f $config ]; then
  echo "(remote) signing config $config not found"
  exit 1
fi

# check profile, if given
if [ -n "$profile" ]; then
  case "$profile" in
    openvpn_server)
      ;;
    openvpn_client)
      ;;
    *)
      echo "Invalid profile given"
      usage
      ;;
  esac
fi

# some sanity checking for insane users
if [ -z "$profile" -a -z "$hostnames" ]; then
  echo "WARNING: default certificate reqested, yet no hostnames given"
  echo "setting hostnames == $cn"
  echo "press Ctrl + C if you don't want this"
  read dummy
  hostnames="$cn"
fi
if [ -n "$profile" -a -n "$hostnames" ]; then
  echo "WARNING: non-SAN enabled profile given, yet hostnames added"
  echo "unsetting hostnames"
  echo "press Ctrl + C if you don't want this"
  read dummy
  hostnames=""
fi

# generate template csr
csr=$(mktemp)
trap "rm -f $csr" EXIT
sed "s,COMMON_NAME,$cn,g" $template > $csr

# generate certificate
# cfssl gencert -config remote_signing.json -profile openvpn_client /tmp/csr.json \
#| cfssljson -bare n8_h2
if [ -n "$hostnames" ]; then
  h_p="-hostname=$hostnames"
else
  h_p=""
fi
cfssl gencert -config=$config -profile=$profile $h_p $csr | cfssljson -bare $cn

Der CSR dazu ist ein Template:

{
  "cn": "COMMON_NAME",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "C": "DE",
      "ST": "Bayern",
      "L": "Muenchen",
      "O": "incertum.net"
    }
  ]
}

Eigentlich[tm] wollte ich helm-Charts schreiben. Oder mich mit Prometheus beschäftigen. Jetzt habe ich TLS. Wie das Leben manchmal so spielt…

cfssl  xca  fpm