author | Alberto Bertogli
<albertito@blitiri.com.ar> 2014-01-12 19:20:24 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2014-04-22 23:56:35 UTC |
.gitignore | +5 | -0 |
Makefile | +69 | -0 |
README | +79 | -0 |
cryptsetup/README | +46 | -0 |
cryptsetup/initramfs-hooks/kxc | +27 | -0 |
cryptsetup/initramfs-scripts/premount-net | +27 | -0 |
cryptsetup/kxc-cryptsetup | +29 | -0 |
doc/man/.gitignore | +1 | -0 |
doc/man/Makefile | +11 | -0 |
doc/man/kxc-cryptsetup.rst | +62 | -0 |
doc/man/kxc.rst | +52 | -0 |
doc/man/kxd.rst | +104 | -0 |
doc/quick_start.rst | +98 | -0 |
kxc/kxc.go | +186 | -0 |
kxd/email.go | +106 | -0 |
kxd/key_config.go | +205 | -0 |
kxd/kxd.go | +228 | -0 |
scripts/create-kxd-config | +36 | -0 |
scripts/default/kxd | +11 | -0 |
scripts/init.d/kxd | +82 | -0 |
scripts/kxc-add-key | +58 | -0 |
scripts/kxd-add-client-key | +37 | -0 |
scripts/systemd/kxd.service | +10 | -0 |
tests/.pylintrc | +8 | -0 |
tests/run_tests | +328 | -0 |
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4db43c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +out/ +*.swp + +# Just in case, we ignore all .pem so noone commits them by accident. +*.pem diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22b86ac --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ + +GO = go +OUTDIR = ./out + +default: kxd kxc + +kxd: + $(GO) build -o $(OUTDIR)/kxd ./kxd + +# For the client, because it can be run in a very limited environment without +# glibc (like initramfs), we build it using the native go networking so it can +# work even when glibc's resolvers are missing. +kxc: + $(GO) build --tags netgo -a -o $(OUTDIR)/kxc ./kxc + +fmt: + gofmt -w . + +vet: + $(GO) tool vet . + +test: kxd kxc + python tests/run_tests -b + +tests: test + + +# Prefixes for installing the files. +PREFIX=/usr +ETCDIR=/etc +SYSTEMDDIR=$(shell pkg-config systemd --variable=systemdsystemunitdir) + +# Install utility, we assume it's GNU/BSD compatible. +INSTALL=install + +install-all: install-kxd install-init.d install-kxc install-initramfs + +install-kxd: kxd + $(INSTALL) -d $(PREFIX)/bin + $(INSTALL) -m 0755 out/kxd $(PREFIX)/bin/ + $(INSTALL) -m 0755 scripts/create-kxd-config $(PREFIX)/bin/ + $(INSTALL) -m 0755 scripts/kxd-add-client-key $(PREFIX)/bin/ + +install-init.d: install-kxd + $(INSTALL) -m 0755 scripts/init.d/kxd $(ETCDIR)/init.d/kxd + $(INSTALL) -m 0644 scripts/default/kxd $(ETCDIR)/default/kxd + +install-systemd: install-kxd + $(INSTALL) -m 0644 scripts/default/kxd $(ETCDIR)/default/kxd + $(INSTALL) -m 0644 scripts/systemd/kxd.service $(SYSTEMDDIR) + +install-kxc: kxc + $(INSTALL) -m 0755 out/kxc $(PREFIX)/bin/ + $(INSTALL) -m 0755 cryptsetup/kxc-cryptsetup $(PREFIX)/bin/ + $(INSTALL) -m 0755 scripts/kxc-add-key $(PREFIX)/bin/ + +install-initramfs: install-kxc + $(INSTALL) -d $(PREFIX)/share/initramfs-tools/hooks/ + $(INSTALL) -m 0755 cryptsetup/initramfs-hooks/kxc \ + $(PREFIX)/share/initramfs-tools/hooks/ + $(INSTALL) -d $(PREFIX)/share/initramfs-tools/scripts/init-premount + $(INSTALL) -m 0755 cryptsetup/initramfs-scripts/premount-net \ + $(PREFIX)/share/initramfs-tools/scripts/init-premount/ + + +.PHONY: kxd kxc +.PHONY: install-all install-kxd install-init.d install-kxc install-initramfs +.PHONY: test tests + diff --git a/README b/README new file mode 100644 index 0000000..41cb678 --- /dev/null +++ b/README @@ -0,0 +1,79 @@ + +kxd - Key exchange daemon +========================= + +kxd is a key exchange daemon, which serves blobs of data (keys) over https. + +It can be used to get keys remotely instead of using local storage. +The main use case is to get keys to open dm-crypt devices automatically, +without having to store them on the local machine. + + +Quick start +----------- + +The document at doc/quick_start.rst contains a step by step guide of a typical +server and client setups. + + +Server configuration +-------------------- + +The server configuration is stored in a root directory (/etc/kxd/data), and +within there, with per-key directories (e.g. /etc/kxd/data/host1/key1), each +containing the following files: + + - key: Contains the key to give to the client. + - allowed_clients: Contains one or more PEM-encoded client certificates that + will be allowed to request the key. + If not present, then no clients will be allowed to access this key. + - allowed_hosts: Contains one or more host names (one per line). + If not present, then all hosts will be allowed to access that key (as long + as they are authorized with a valid client certificate). + - email_to: Contains one or more email destinations to notify (one per line). + If not present, then no notifications will be sent upon key accesses. + + +Client configuration +-------------------- + +The basic command line client (kxc) will take the client key and certificate, +the expected server certificate, and a URL to the server (like +kxd://server/host1/key1), and it will print on standard output the returned +key (the contents of the corresponding key file). + +There are scripts to tie this with cryptsetup's infrastructure to make the +opening of encrypted devices automatic; see cryptsetup/ for the details. + + +Security +-------- + +All traffic between the server and the clients goes over SSL, using the +provided server certificate. + +The clients are authenticated and authorized based on their SSL client +certificates matching the ones associated with the key in the server +configuration, not using a root of trust (for now). + +Likewise, the clients will authenticate the server based on a certificate +given on the command line, and will only accept keys from it. + + +Note the server will return reasonably detailed information on errors, for +example it will tell when a key is not found vs. when the client is not +allowed. While this leaks some information about existance of keys, it makes +troubleshooting much easier. + +The server itself makes no effort to protect the data internally; for example, +there is no on-disk encryption, and memory is not locked. We work under the +assumption that the server's host is secure and trusted. + + +Bugs and contact +---------------- + +Please report bugs to albertito@blitiri.com.ar. + +The latest version can be found at http://blitiri.com.ar/p/kxd/. + diff --git a/cryptsetup/README b/cryptsetup/README new file mode 100644 index 0000000..03ea7fa --- /dev/null +++ b/cryptsetup/README @@ -0,0 +1,46 @@ + +These are scripts for integration with cryptsetup (and initramfs). + +They are tested on a Debian install, so they may not be vendor-neutral +although they should work with an standard initramfs-tools and cryptsetup +environment. + +For an example of how to use it, see doc/quick_start.rst. + + +What if something goes wrong +============================ + +If the key fetch fails or is incorrect it will be retried, and after 3 +attempts, it will give up and return an initramfs prompt, which you can use to +manually recover. + +In modern Debian installs, you can just unlock the device (for example using +"cryptsetup luksOpen /dev/sdXX sdXX_crypt"), and then exit. + +The init scripts will recognise they can now proceed with the usual boot +process. + + +How does it work +================ + +The first part of the work happens when update-initramfs runs: + + - The initramfs hook script copies the kxc binary and all the configuration + from /etc/kxc. + - The standard cryptsetup hook will copy kxc-cryptsetup if it sees it + appearing in /etc/crypttab. + - The premount-net script will be copied. + +Then, when the machine boots: + + - Before attempting to mount root, the premount-net script will run, + configure networking, and create a minimal /etc/resolv.conf. + - When attempting to mount root, assuming it is encrypted and properly + configured, the cryptsetup scripts will invoke the keyfile, kxc-cryptsetup. + - kxc-cryptsetup will run the kxc client with the right configuration taken + from /etc/kxc. + - The device is unlocked with the key, and boot continues as usual. + + diff --git a/cryptsetup/initramfs-hooks/kxc b/cryptsetup/initramfs-hooks/kxc new file mode 100755 index 0000000..ab78e6f --- /dev/null +++ b/cryptsetup/initramfs-hooks/kxc @@ -0,0 +1,27 @@ +#!/bin/sh + +set -e + +PREREQ="cryptroot" + +prereqs() +{ + echo "$PREREQ" +} + +case $1 in +prereqs) + prereqs + exit 0 + ;; +esac + +. /usr/share/initramfs-tools/hook-functions + +# Install binaries into initramfs. +# Note we don't need to install kxc-cryptsetup, as the cryptroot hook will do +# it for us if it sees it being used as a keyscript. +copy_exec /usr/bin/kxc /bin + +# Install the configuration into initramfs +cp -a /etc/kxc/ ${DESTDIR}/etc diff --git a/cryptsetup/initramfs-scripts/premount-net b/cryptsetup/initramfs-scripts/premount-net new file mode 100755 index 0000000..b131058 --- /dev/null +++ b/cryptsetup/initramfs-scripts/premount-net @@ -0,0 +1,27 @@ +#!/bin/sh + +# Configure networking before mounting. + +PREREQ="" + +prereqs() +{ + echo "$PREREQ" +} + +case $1 in +# get pre-requisites +prereqs) + prereqs + exit 0 + ;; +esac + +. /scripts/functions + +configure_networking + +# Configure a basic resolv.conf based on our networking. +echo "nameserver $IPV4DNS0" >> /etc/resolv.conf + + diff --git a/cryptsetup/kxc-cryptsetup b/cryptsetup/kxc-cryptsetup new file mode 100755 index 0000000..7ccfc49 --- /dev/null +++ b/cryptsetup/kxc-cryptsetup @@ -0,0 +1,29 @@ +#!/bin/sh + +# Script to use as a crypttab keyscript, to automatically get keys with kxc. +# It will use the configuration from /etc/kxc/. +# +# The only argument is the base name of the configuration. + +CONFIG_BASE="/etc/kxc" + +CLIENT_CERT="${CONFIG_BASE}/cert.pem" +CLIENT_KEY="${CONFIG_BASE}/key.pem" +SERVER_CERT="${CONFIG_BASE}/${1}.server_cert.pem" +SERVER_URL=$(cat "${CONFIG_BASE}/${1}.url") + +# Find the binary. We search because it can be in one place in the initramfs, +# and in another in the normal distribution, and we want to support both +# easily. +for KXC in /bin/kxc /sbin/kxc /usr/bin/kxc /usr/sbin/kxc; do + if [ -x $KXC ]; then + break; + fi +done + +exec $KXC \ + --client_cert=$CLIENT_CERT \ + --client_key=$CLIENT_KEY \ + --server_cert=$SERVER_CERT \ + $SERVER_URL + diff --git a/doc/man/.gitignore b/doc/man/.gitignore new file mode 100644 index 0000000..f7e585b --- /dev/null +++ b/doc/man/.gitignore @@ -0,0 +1 @@ +*.1 diff --git a/doc/man/Makefile b/doc/man/Makefile new file mode 100644 index 0000000..22c9bec --- /dev/null +++ b/doc/man/Makefile @@ -0,0 +1,11 @@ + +default: manpages + +%.1: %.rst + rst2man < $^ > $@ + +manpages: kxd.1 kxc.1 kxc-cryptsetup.1 + +clean: + rm -f kxd.1 kxc.1 kxc-cryptsetup.1 + diff --git a/doc/man/kxc-cryptsetup.rst b/doc/man/kxc-cryptsetup.rst new file mode 100644 index 0000000..299e156 --- /dev/null +++ b/doc/man/kxc-cryptsetup.rst @@ -0,0 +1,62 @@ + +================ + kxc-cryptsetup +================ + +------------------------ +Cryptsetup helper to kxc +------------------------ + +:Author: Alberto Bertogli <albertito@blitiri.com.ar> +:Manual section: 1 + + +SYNOPSIS +======== + +kxc-cryptsetup <NAME> + + +DESCRIPTION +=========== + +``kxc(1)`` is a client for kxd, a key exchange daemon. + +kxc-cryptsetup is a convenience wrapper for invoking kxc while taking the +options from the files in ``/etc/kxc/``. + +It can be used as a cryptsetup keyscript, to automatically get keys to open +encrypted devices with kxc. + + +OPTIONS +======= + +Its only command-line argument is a descriptive name, which will be used to +find the configuration files. + + +FILES +===== + +For a given *NAME* that is passed as the only command-line argument, the +following files are needed: + +/etc/kxc/NAME.key.pem + Private key to use. + +/etc/kxc/NAME.cert.pem + Certificate to use. Must match the given key. + +/etc/kxc/NAME.server_cert.pem + Server certificate, used to validate the server. + +/etc/kxc/NAME.url + Contains the URL to the key; usually in the form of ``kxd://server/name``. + + +SEE ALSO +======== + +``kxc(1)``, ``kxd(1)``, ``crypttab(5)``, ``cryptsetup(8)``. + diff --git a/doc/man/kxc.rst b/doc/man/kxc.rst new file mode 100644 index 0000000..c6cd762 --- /dev/null +++ b/doc/man/kxc.rst @@ -0,0 +1,52 @@ + +===== + kxc +===== + +------------------- +Key exchange client +------------------- +:Author: Alberto Bertogli <albertito@blitiri.com.ar> +:Manual section: 1 + + +SYNOPSIS +======== + +kxc --client_cert=<file> --client_key=<file> --server_cert=<file> <URL> + + +DESCRIPTION +=========== + +kxc is a client for kxd, a key exchange daemon. + +It will take a client key and certificate, the expected server certificate, +and a URL to the server (like ``kxd://server/host1/key1``), and it will print +on standard output the returned key (the contents of the corresponding key +file on the server). + +There are scripts to tie this with cryptsetup's infrastructure to make the +opening of encrypted devices automatic; see ``kxc-cryptsetup(1)`` for the +details. + + +OPTIONS +======= + +--client_key=<file> + File containing the client private key (in PAM format). + +--client_cert=<file> + File containing the client certificate that corresponds to the given key (in + PAM format). + +--server_cert=<file> + File containing valid server certificate(s). + + +SEE ALSO +======== + +``kxc-cryptsetup(1)``, ``kxd(1)``. + diff --git a/doc/man/kxd.rst b/doc/man/kxd.rst new file mode 100644 index 0000000..5383da0 --- /dev/null +++ b/doc/man/kxd.rst @@ -0,0 +1,104 @@ + +===== + kxd +===== + +------------------- +Key exchange daemon +------------------- + +:Author: Alberto Bertogli <albertito@blitiri.com.ar> +:Manual section: 1 + + +SYNOPSIS +======== + +kxd [--key=<file>] [--cert=<file>] [--data_dir=<directory>] +[--email_from=<email-address>] [--ip_addr=<ip-address>] [--logfile=<file>] +[--port=<port>] [--smtp_addr=<host:port>] + + +DESCRIPTION +=========== + +kxd is a key exchange daemon, which serves blobs of data (keys) over https. + +It can be used to get keys remotely instead of using local storage. +The main use case is to get keys to open dm-crypt devices automatically, +without having to store them on the local machine. + + +SETUP +===== + +The server configuration is stored in a root directory (``/etc/kxd/data/`` by +default), and within there, with per-key directories (e.g. +``/etc/kxd/data/host1/key1/``), each containing the following files: + + - ``key``: Contains the key to give to the client. + - ``allowed_clients``: Contains one or more PEM-encoded client certificates + that will be allowed to request the key. + If not present, then no clients will be allowed to access this key. + - ``allowed_hosts``: Contains one or more host names (one per line). + If not present, then all hosts will be allowed to access that key (as long + as they are authorized with a valid client certificate). + - ``email_to``: Contains one or more email destinations to notify (one per + line). If not present, then no notifications will be sent upon key + accesses. + + +OPTIONS +======= + +--key=<file> + Private key to use. + Defaults to /etc/kxd/key.pem. + +--cert=<file> + Certificate to use; must match the given key. + Defaults to /etc/kxd/cert.pem. + +--data_dir=<directory> + Data directory, where the key and configuration live (see the SETUP section + above). + Defaults to /etc/kxd/data. + +--email_from=<email-address> + Email address to send email from. + +--ip_addr=<ip-address> + IP address to listen on. + Defaults to 0.0.0.0, which means all. + +--logfile=<file> + File to write logs to, use '-' for stdout. + By default, the daemon will log to syslog. + +--port=<port> + Port to listen on. + The default port is 19840. + +--smtp_addr=<host:port> + Address of the SMTP server to use to send emails. + If none is given, then emails will not be sent. + + +FILES +===== + +/etc/kxd/key.pem + Private key to use for SSL. + +/etc/kxd/cert.pem + Certificate to use for SSL. Must match the given private key. + +/etc/kxd/data/ + Directory where the keys and their configuration are stored. + + +SEE ALSO +======== + +``kxc(1)``, ``kxc-cryptsetup(1)``. + diff --git a/doc/quick_start.rst b/doc/quick_start.rst new file mode 100644 index 0000000..5a53a46 --- /dev/null +++ b/doc/quick_start.rst @@ -0,0 +1,98 @@ + +=================================== + Key Exchange Daemon - Quick start +=================================== + +In this guide we show how to set up a key exchange daemon and client +on a typical scenario where the keys are used to open a device encrypted with +dm-crypt (the standard Linux disk encryption). + +``server`` is the hostname of the server. +``client`` is the hostname of the client. +``sda2`` is the encrypted drive. + + +Initial server setup +==================== + +First of all, install *kxd* on the server, usually via your distribution +packages, or directly from source. + +Then, run ``create-kxd-config``, which will create the configuration +directories, and generate the server key/cert pair. Everything is in +``/etc/kxd/``. + + +Initial client setup +==================== + +Install *kxc* on the client machine, usually via your distribution packages, +or directly from source. + + +Then, run ``kxc-add-key server sda2``, which will create the configuration +directories, and generate the client key/cert pair, and also create an entry +for an ``client/sda2`` key to be fetched from the server. +Everything is in ``/etc/kxc/``. + +Finally, copy the server public certificate over, using +``scp server:/etc/kxd/cert.pem /etc/kxc/sda2.server_cert.pem`` (or something +equivalent). + + +Adding the key to the server +============================ + +On the server, run ``kxd-add-client-key client sda2`` to generate the basic +configuration for that client's key, including the key itself (generated +randomly). + +Then, copy the client public certificate over, using +``scp client:/etc/kxc/cert.pem /etc/kxd/data/client/sda2/allowed_clients`` +(or something equivalent). + +That allows the client to fetch the key. + + +Updating the drive's key +======================== + +On the client, run ``kxc-cryptsetup sda2 | wc -c`` to double-check that the +output length is as expected (you could also compare it by running sha256 or +something equivalent). + +Assuming that goes well, all you need is to add that key to your drives' key +ring so it can be decrypted with it:: + + # Note we copy to /dev/shm which should not be written to disk. + kxc-cryptsetup sda2 > /dev/shm/key + + cryptsetup luksAddKey /dev/sda2 /dev/shm/key + + rm /dev/shm/key + +Note this *adds* a new key, but your existing ones are still valid. Always +have more than one key, so if something goes wrong with kxd, you can still +unlock the drive manually. + + +Configuring crypttab +==================== + +In order to get kxc to be run automatically to fetch the key, we need to edit +``/etc/crypttab`` and tell it to use a keyscript:: + + sda2_crypt UUID=blah-blah-blah sda2 luks,keyscript=kxc-cryptsetup + ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^ + +Note the "sda2" field corresponds to the name we've been passing around in +previous sections. The ``keyscript=kxc-cryptsetup`` is our way of telling the +cryptsetup infrastructure to use our script to fetch the key for this target. + + +You can test that this works by using:: + + cryptdisks_stop sda2_crypt + cryptdisks_start sda2_crypt + +The second command should issue a request to your server to get the key. diff --git a/kxc/kxc.go b/kxc/kxc.go new file mode 100644 index 0000000..66860e9 --- /dev/null +++ b/kxc/kxc.go @@ -0,0 +1,186 @@ +// kxc is a client for the key exchange daemon kxd. +// +// It connects to the given server using the provided certificate, +// and authorizes the server against the given server certificate. +// +// If everything goes well, it prints the obtained key to standard output. +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "strings" +) + +const defaultPort = 19840 + +var server_cert = flag.String( + "server_cert", "", "File containing valid server certificate(s)") +var client_cert = flag.String( + "client_cert", "", "File containing the client certificate") +var client_key = flag.String( + "client_key", "", "File containing the client private key") + +func LoadServerCerts() ([]*x509.Certificate, error) { + pemData, err := ioutil.ReadFile(*server_cert) + if err != nil { + return nil, err + } + + var certs []*x509.Certificate + for len(pemData) > 0 { + var block *pem.Block + block, pemData = pem.Decode(pemData) + if block == nil { + return certs, nil + } + + if block.Type != "CERTIFICATE" { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return certs, err + } + + certs = append(certs, cert) + } + + return certs, nil +} + +// We can't ask the http client for the TLS connection, so we make it +// ourselves and save it here. +// It is hacky but it simplifies the code. +var tlsConn *tls.Conn + +func OurDial(network, addr string) (net.Conn, error) { + var err error + + tlsConf := &tls.Config{} + tlsConf.Certificates = make([]tls.Certificate, 1) + tlsConf.Certificates[0], err = tls.LoadX509KeyPair( + *client_cert, *client_key) + if err != nil { + log.Fatalf("Failed to load keys: %s", err) + } + + // We don't want the TLS stack to check the cert, as we will compare + // it ourselves. + tlsConf.InsecureSkipVerify = true + + tlsConn, err = tls.Dial(network, addr, tlsConf) + return tlsConn, err +} + +// Check if any cert from requestedCerts matches any cert in validCerts. +func AnyCertMatches(requestedCerts, validCerts []*x509.Certificate) bool { + for _, cert := range requestedCerts { + for _, validCert := range validCerts { + if cert.Equal(validCert) { + return true + } + } + } + + return false +} + +// Check if the given network address has a port. +func hasPort(s string) bool { + // Consider the IPv6 case (where the host part contains ':') by + // checking if the last ':' comes after the ']' which closes the host. + return strings.LastIndex(s, ":") > strings.LastIndex(s, "]") +} + +func ExtractURL(rawurl string) (*url.URL, error) { + // Because we handle the transport ourselves, the http library has to + // be told to use plain http; otherwise it will attempt to do its own + // TLS on top of our TLS. + serverURL, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + + // Fix up the scheme to use http. + // Because we handle the transport ourselves, the http library has to + // be told to use plain http; otherwise it will attempt to do its own + // TLS on top of our TLS (and obviously fails, in this case with an + // "local error: record overflow" error). + switch serverURL.Scheme { + case "http": + // Nothing to do here. + case "https", "kxd": + serverURL.Scheme = "http" + default: + return nil, fmt.Errorf("Unsupported URL schema (try kxd://)") + } + + // The path must begin with /v1/, although we hide that from the user + // for forward compatibility. + if !strings.HasPrefix(serverURL.Path, "/v1/") { + serverURL.Path = "/v1" + serverURL.Path + } + + // Add the default port, if none was given. + if !hasPort(serverURL.Host) { + serverURL.Host += fmt.Sprintf(":%d", defaultPort) + } + + return serverURL, nil +} + +func main() { + var err error + flag.Parse() + + serverCerts, err := LoadServerCerts() + if err != nil { + log.Fatalf("Failed to load server certs: %s", err) + } + + tr := &http.Transport{ + Dial: OurDial, + } + + client := &http.Client{ + Transport: tr, + } + + serverURL, err := ExtractURL(flag.Arg(0)) + if err != nil { + log.Fatalf("Failed to extract the URL: %s", err) + } + + resp, err := client.Get(serverURL.String()) + if err != nil { + log.Fatalf("Failed to get key: %s", err) + } + + content, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + log.Fatalf("Error reading key body: %s", err) + } + + if resp.StatusCode != 200 { + log.Fatalf("HTTP error %q getting key: %s", + resp.Status, content) + } + + if !AnyCertMatches( + tlsConn.ConnectionState().PeerCertificates, serverCerts) { + log.Fatalf("No server certificate matches") + } + + fmt.Printf("%s", content) +} diff --git a/kxd/email.go b/kxd/email.go new file mode 100644 index 0000000..d6ca469 --- /dev/null +++ b/kxd/email.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "net/smtp" + "strings" + "text/template" + "time" +) + +type EmailBody struct { + From string + To string + Key string + Time time.Time + TimeString string + Req *Request + Cert *x509.Certificate +} + +const emailTmplBody = (`Date: {{.TimeString}} +From: Key Exchange Daemon <{{.From}}> +To: {{.To}} +Subject: Access to key {{.Key}} + +Key: {{.Key}} +Accessed by: {{.Req.RemoteAddr}} +On: {{.TimeString}} + +Client certificate: + Signature: {{printf "%.16s" (printf "%x" .Cert.Signature)}}... + Issuer: {{NameToString .Cert.Issuer}} + Subject: {{NameToString .Cert.Subject}} + +`) + +var emailTmpl = template.New("email") + +func init() { + emailTmpl.Funcs(map[string]interface{}{ + "NameToString": NameToString, + }) + + template.Must(emailTmpl.Parse(emailTmplBody)) +} + +func NameToString(name pkix.Name) string { + s := make([]string, 0) + for _, c := range name.Country { + s = append(s, fmt.Sprintf("C=%s", c)) + } + for _, o := range name.Organization { + s = append(s, fmt.Sprintf("O=%s", o)) + } + + if name.CommonName != "" { + s = append(s, fmt.Sprintf("N=%s", name.CommonName)) + } + + return strings.Join(s, " ") +} + +func SendMail(kc *KeyConfig, req *Request, cert *x509.Certificate) error { + if *smtp_addr == "" { + req.Printf("Skipping notifications") + return nil + } + + emailTo, err := kc.EmailTo() + if err != nil { + return err + } + + if emailTo == nil { + return nil + } + + keyPath, err := req.KeyPath() + if err != nil { + return err + } + + now := time.Now() + body := EmailBody{ + From: *email_from, + To: strings.Join(emailTo, ", "), + Key: keyPath, + Time: now, + TimeString: now.Format(time.RFC1123Z), + Req: req, + Cert: cert, + } + + msg := new(bytes.Buffer) + + err = emailTmpl.Execute(msg, body) + if err != nil { + return err + } + + return smtp.SendMail(*smtp_addr, nil, *email_from, emailTo, + msg.Bytes()) +} diff --git a/kxd/key_config.go b/kxd/key_config.go new file mode 100644 index 0000000..5b9e491 --- /dev/null +++ b/kxd/key_config.go @@ -0,0 +1,205 @@ +package main + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "net" + "os" + "strings" +) + +func FileStat(path string) (os.FileInfo, error) { + fd, err := os.Open(path) + if err != nil { + return nil, err + } + + return fd.Stat() +} + +func IsDir(path string) (bool, error) { + fi, err := FileStat(path) + if err != nil { + return false, err + } + + return fi.IsDir(), nil +} + +func IsRegular(path string) (bool, error) { + fi, err := FileStat(path) + if err != nil { + return false, err + } + + return fi.Mode().IsRegular(), nil +} + +// KeyConfig holds the configuration data for a single key. +type KeyConfig struct { + // Path to the configuration directory. + ConfigPath string + + // Paths to the files themselves. + keyPath string + allowedClientsPath string + allowedHostsPath string + emailToPath string + + // Allowed certificates. + allowedClientCerts []*x509.Certificate + + // Allowed hosts. + allowedHosts []string +} + +func NewKeyConfig(configPath string) *KeyConfig { + return &KeyConfig{ + ConfigPath: configPath, + keyPath: configPath + "/key", + allowedClientsPath: configPath + "/allowed_clients", + allowedHostsPath: configPath + "/allowed_hosts", + emailToPath: configPath + "/email_to", + } +} + +func (kc *KeyConfig) Exists() (bool, error) { + isDir, err := IsDir(kc.ConfigPath) + if os.IsNotExist(err) { + return false, nil + } else if err != nil { + return false, err + } + if !isDir { + return false, nil + } + + isRegular, err := IsRegular(kc.keyPath) + if os.IsNotExist(err) { + return false, nil + } else if err != nil { + return false, err + } + + return isRegular, nil +} + +func (kc *KeyConfig) LoadClientCerts() error { + rawContents, err := ioutil.ReadFile(kc.allowedClientsPath) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + for len(rawContents) > 0 { + var block *pem.Block + block, rawContents = pem.Decode(rawContents) + if block == nil { + return nil + } + + if block.Type != "CERTIFICATE" { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + + kc.allowedClientCerts = append(kc.allowedClientCerts, cert) + } + + return nil +} + +func (kc *KeyConfig) LoadAllowedHosts() error { + contents, err := ioutil.ReadFile(kc.allowedHostsPath) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + // If the file is there, we want our array to exist, even if it's + // empty, to avoid authorizing everyone on an empty file (which means + // authorize noone). + kc.allowedHosts = make([]string, 1) + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if net.ParseIP(line) != nil { + kc.allowedHosts = append(kc.allowedHosts, line) + } else { + names, err := net.LookupHost(line) + if err != nil { + continue + } + kc.allowedHosts = append(kc.allowedHosts, names...) + } + } + + return nil +} + +func (kc *KeyConfig) IsAnyCertAllowed( + certs []*x509.Certificate) *x509.Certificate { + for _, cert := range certs { + for _, allowedCert := range kc.allowedClientCerts { + if cert.Equal(allowedCert) { + return cert + } + } + } + return nil +} + +func (kc *KeyConfig) IsHostAllowed(addr string) error { + if kc.allowedHosts == nil { + return nil + } + + host, _, err := net.SplitHostPort(addr) + if err != nil { + return err + } + + for _, allowedHost := range kc.allowedHosts { + if allowedHost == host { + return nil + } + } + + return fmt.Errorf("Host %q not allowed", host) +} + +func (kc *KeyConfig) Key() (key []byte, err error) { + return ioutil.ReadFile(kc.keyPath) +} + +func (kc *KeyConfig) EmailTo() ([]string, error) { + contents, err := ioutil.ReadFile(kc.emailToPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var emails []string + for _, line := range strings.Split(string(contents), "\n") { + email := strings.TrimSpace(line) + if !strings.Contains(email, "@") { + continue + } + emails = append(emails, email) + } + + return emails, nil +} diff --git a/kxd/kxd.go b/kxd/kxd.go new file mode 100644 index 0000000..3f1fd71 --- /dev/null +++ b/kxd/kxd.go @@ -0,0 +1,228 @@ +// kxd is a key exchange daemon. +// +// It serves blobs of data (keys) over https, authenticating and authorizing +// the clients using SSL certificates, and notifying upon key accesses. +// +// It can be used to get keys remotely instead of using local storage. +// The main use case is to get keys to open dm-crypt devices automatically, +// without having to store them on the machine. +package main + +import ( + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "io" + "log" + "log/syslog" + "net/http" + "os" + "path" + "strings" +) + +var port = flag.Int( + "port", 19840, "Port to listen on") +var ip_addr = flag.String( + "ip_addr", "", "IP address to listen on") +var data_dir = flag.String( + "data_dir", "/etc/kxd/data", "Data directory") +var certfile = flag.String( + "cert", "/etc/kxd/cert.pem", "Certificate") +var keyfile = flag.String( + "key", "/etc/kxd/key.pem", "Private key") +var smtp_addr = flag.String( + "smtp_addr", "", "Address of the SMTP server to use to send emails") +var email_from = flag.String( + "email_from", "", "Email address to send email from") +var logfile = flag.String( + "logfile", "", "File to write logs to, use '-' for stdout") + +// Logger we will use to log entries. +var logging *log.Logger + +// Request is our wrap around http.Request, so we can augment it with custom +// methods. +type Request struct { + *http.Request +} + +func (req *Request) Printf(format string, a ...interface{}) { + msg := fmt.Sprintf(format, a...) + msg = fmt.Sprintf("%s %s %s", req.RemoteAddr, req.URL.Path, msg) + logging.Output(2, msg) +} + +// KeyPath returns the path to the requested key, extracting it from the URL. +func (req *Request) KeyPath() (string, error) { + s := strings.Split(req.URL.Path, "/") + + // We expect the path to be "/v1/path/to/key". + if len(s) < 2 || !(s[0] == "" || s[1] == "v1") { + return "", fmt.Errorf("Invalid path %q", s) + } + + return strings.Join(s[2:], "/"), nil +} + +func CertToString(cert *x509.Certificate) string { + return fmt.Sprintf( + "<signature:0x%.8s>", fmt.Sprintf("%x", cert.Signature)) +} + +// HandlerV1 handles /v1/ key requests. +func HandlerV1(w http.ResponseWriter, httpreq *http.Request) { + req := Request{httpreq} + if len(req.TLS.PeerCertificates) <= 0 { + req.Printf("Rejecting request without certificate") + http.Error(w, "Client certificate not provided", + http.StatusNotAcceptable) + return + } + + keyPath, err := req.KeyPath() + if err != nil { + req.Printf("Rejecting request with invalid key path: %s", err) + http.Error(w, "Invalid key path", http.StatusNotAcceptable) + return + } + + // Be extra paranoid and reject keys with "..", even if they're valid + // (e.g. "/v1/x..y" is valid, but will get rejected anyway). + if strings.Contains(keyPath, "..") { + req.Printf("Rejecting because requested key %q contained '..'", + keyPath) + req.Printf("Full request: %+v", *req.Request) + http.Error(w, "Invalid key path", http.StatusNotAcceptable) + return + } + + realKeyPath := path.Clean(*data_dir + "/" + keyPath) + keyConf := NewKeyConfig(realKeyPath) + + exists, err := keyConf.Exists() + if err != nil { + req.Printf("Error checking key path %q: %s", keyPath, err) + http.Error(w, "Error checking key", + http.StatusInternalServerError) + return + } + if !exists { + req.Printf("Unknown key path %q", keyPath) + http.Error(w, "Unknown key", http.StatusNotFound) + return + } + + if err = keyConf.LoadClientCerts(); err != nil { + req.Printf("Error loading certs: %s", err) + http.Error(w, "Error loading certs", + http.StatusInternalServerError) + return + } + + if err = keyConf.LoadAllowedHosts(); err != nil { + req.Printf("Error loading allowed hosts: %s", err) + http.Error(w, "Error loading allowed hosts", + http.StatusInternalServerError) + return + } + + err = keyConf.IsHostAllowed(req.RemoteAddr) + if err != nil { + req.Printf("Host not allowed: %s", err) + http.Error(w, "Host not allowed", http.StatusForbidden) + return + } + + validCert := keyConf.IsAnyCertAllowed(req.TLS.PeerCertificates) + if validCert == nil { + req.Printf("No allowed certificate found") + http.Error(w, "No allowed certificate found", + http.StatusForbidden) + return + } + + keyData, err := keyConf.Key() + if err != nil { + req.Printf("Error getting key data: %s", err) + http.Error(w, "Error getting key data", + http.StatusInternalServerError) + return + } + + req.Printf("Allowing request to cert %s", CertToString(validCert)) + + err = SendMail(keyConf, &req, validCert) + if err != nil { + req.Printf("Error sending notification: %s", err) + http.Error(w, "Error sending notification", + http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(keyData) +} + +func initLog() { + var err error + var logfd io.Writer + + if *logfile == "-" { + logfd = os.Stdout + } else if *logfile != "" { + logfd, err = os.OpenFile(*logfile, + os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + log.Fatalf("Error opening log file %s: %s", + *logfile, err) + } + } else { + logfd, err = syslog.New( + syslog.LOG_INFO|syslog.LOG_DAEMON, "kxd") + if err != nil { + log.Fatalf("Error opening syslog: %s", err) + } + } + + logging = log.New(logfd, "", + log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile) +} + +func main() { + flag.Parse() + + initLog() + + if *smtp_addr == "" { + logging.Print( + "WARNING: No emails will be sent, use --smtp_addr") + } + + if *email_from == "" { + // Try to get a sane default if not provided, using + // kxd@<smtp host>. + *email_from = fmt.Sprintf("kxd@%s", + strings.Split(*smtp_addr, ":")[0]) + } + + listenAddr := fmt.Sprintf("%s:%d", *ip_addr, *port) + + tlsConfig := tls.Config{ + ClientAuth: tls.RequireAnyClientCert, + } + + server := http.Server{ + Addr: listenAddr, + TLSConfig: &tlsConfig, + } + + http.HandleFunc("/v1/", HandlerV1) + + logging.Printf("Listening on %s", listenAddr) + err := server.ListenAndServeTLS(*certfile, *keyfile) + if err != nil { + logging.Fatal(err) + } +} diff --git a/scripts/create-kxd-config b/scripts/create-kxd-config new file mode 100755 index 0000000..2513b48 --- /dev/null +++ b/scripts/create-kxd-config @@ -0,0 +1,36 @@ +#!/bin/bash +# +# Create a basic but functional kxd configuration. +# +# This script creates the /etc/kxd directory, and generates a certificate for +# the server to use. +# +# It should be run under the same user as kxd itself. + +set -e + +# Create the base configuration directory. +echo "Creating directories (/etc/kxd/)" +mkdir -p /etc/kxd/ + +# And the data directory where the keys are stored. +mkdir -p /etc/kxd/data + +# Create a private key for the server. +if ! [ -e /etc/kxd/key.pem ]; then + echo "Generating private key (/etc/kxd/key.pem)" + openssl genrsa -out /etc/kxd/key.pem 2048 + chmod 400 /etc/kxd/key.pem +else + echo "Private key already exists (/etc/kxd/key.pem)" +fi + +# And a self-signed certificate. +if ! [ -e /etc/kxd/cert.pem ]; then + echo "Generating certificate (/etc/kxd/cert.pem)" + openssl req -new -x509 -batch \ + -subj "/organizationalUnitName=kxd@$HOSTNAME/" \ + -key /etc/kxd/key.pem -out /etc/kxd/cert.pem +else + echo "Certificate already exists (/etc/kxd/cert.pem)" +fi diff --git a/scripts/default/kxd b/scripts/default/kxd new file mode 100644 index 0000000..a54f84e --- /dev/null +++ b/scripts/default/kxd @@ -0,0 +1,11 @@ +# Options for kxd. + +# Set this if you don't want the daemon to be started automatically. +# Note this is only useful for sysv-like init; systemd will ignore it (use +# "sysctl enable/disable" instead). +#DISABLE=1 + +# Set kxd options here. +# OPTS="--smtp_addr example.org:25" +OPTS="" + diff --git a/scripts/init.d/kxd b/scripts/init.d/kxd new file mode 100755 index 0000000..0b5b031 --- /dev/null +++ b/scripts/init.d/kxd @@ -0,0 +1,82 @@ +#! /bin/sh + +### BEGIN INIT INFO +# Provides: kxd +# Required-Start: $remote_fs $syslog +# Required-Stop: $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: +# Short-Description: key exchange daemon +# Description: kxd is a program that serves keys to authorized clients. +### END INIT INFO + +DAEMON=/usr/bin/kxd +DEFAULTS_FILE=/etc/default/kxd + +# These variables can be overriden in the defaults file. +DISABLE= +OPTS='' +PID_FILE=/var/run/kxd.pid + +test -x $DAEMON || exit 0 + +. /lib/lsb/init-functions + +if [ -s $DEFAULTS_FILE ]; then + . $DEFAULTS_FILE +fi + + +case "$1" in + start) + if [ "$DISABLE" != "" ]; then + log_warning_msg "kxd not enabled in $DEFAULTS_FILE" + exit 0 + fi + + log_daemon_msg "Starting kxd" + start-stop-daemon --start --quiet --background \ + --pidfile $PID_FILE --make-pidfile \ + --exec $DAEMON -- $OPTS + case "$?" in + 0) + log_progress_msg "kxd" + log_end_msg 0 + exit 0 + ;; + 1) + log_warning_msg "already running" + exit 0 + ;; + *) + log_failure_msg "failed to start daemon" + exit 1 + ;; + esac + + ;; + + stop) + log_daemon_msg "Stopping kxd daemon" "kxd" + start-stop-daemon --stop --quiet --oknodo --pidfile $PID_FILE + log_end_msg $? + rm -f $PID_FILE + ;; + + restart) + set +e + $0 stop + sleep 2 + $0 start + ;; + + status) + status_of_proc -p $PID_FILE "$DAEMON" kxd + exit $? + ;; + *) + echo "Usage: /etc/init.d/kxd {start|stop|restart|status}" + exit 1 +esac + +exit 0 diff --git a/scripts/kxc-add-key b/scripts/kxc-add-key new file mode 100755 index 0000000..6d57a81 --- /dev/null +++ b/scripts/kxc-add-key @@ -0,0 +1,58 @@ +#!/bin/bash +# +# Add a new key to kxc's configuration (initializing it if necessary). +# +# If /etc/kxc is missing, this script creates it, as well as the required +# client certificates. +# +# Then, it adds configuration for fetching a given key. + +set -e + +SERVER="$1" +KEYNAME="$2" + +if [ "$SERVER" = "" ] || [ "$KEYNAME" = "" ]; then + echo " +Usage: kxc-add-key <server hostname> <key name> + +This command adds a new key to kxc's configuration, initializing it if +necessary. +" + exit 1 +fi + + +# Create the base configuration directory. +echo "Creating directories (/etc/kxc/)" +mkdir -p /etc/kxc/ + +# Create a private key for the client. +if ! [ -e /etc/kxc/key.pem ]; then + echo "Generating private key (/etc/kxc/key.pem)" + openssl genrsa -out /etc/kxc/key.pem 2048 + chmod 400 /etc/kxc/key.pem +else + echo "Private key already exists (/etc/kxc/key.pem)" +fi + +# And a self-signed certificate. +if ! [ -e /etc/kxc/cert.pem ]; then + echo "Generating certificate (/etc/kxc/cert.pem)" + openssl req -new -x509 -batch \ + -subj "/organizationalUnitName=kxc@$HOSTNAME/" \ + -key /etc/kxc/key.pem -out /etc/kxc/cert.pem +else + echo "Certificate already exists (/etc/kxc/cert.pem)" +fi + +echo "Setting URL to kxd://$SERVER/$HOSTNAME/$KEYNAME" +echo "kxd://$SERVER/$HOSTNAME/$KEYNAME" > "/etc/kxc/${KEYNAME}.url" + +echo +echo +echo "YOU need to copy the server certificate to" +echo "/etc/kxc/${KEYNAME}.server_cert.pem. For example, using:" +echo +echo " $ scp $SERVER:/etc/kxd/cert.pem /etc/kxc/${KEYNAME}.server_cert.pem" +echo diff --git a/scripts/kxd-add-client-key b/scripts/kxd-add-client-key new file mode 100755 index 0000000..eff0d56 --- /dev/null +++ b/scripts/kxd-add-client-key @@ -0,0 +1,37 @@ +#!/bin/bash + +set -e + +CLIENT="$1" +KEYNAME="$2" + +if [ "$SERVER" = "" ] || [ "$KEYNAME" = "" ]; then + echo " +Usage: kxd-add-client-key <client hostname> <key name> + +This command is a helper for adding a new key to kxd's configuration. +It takes the hostname of the client and the key name, and puts the +corresponding configuration (including a randomly generated key) in +/etc/kxd/data/<client hostname>/<key name>/. +" + exit 1 + +CONFIGPATH="/etc/kxd/data/$SERVER/$KEYNAME" + +echo "Creating directory ($CONFIGPATH)" +mkdir -p "$CONFIGPATH" + +echo "Generating random key from /dev/urandom ($CONFIGPATH/key)" +dd if=/dev/urandom of="$CONFIGPATH/key" bs=1k count=2 +echo + +echo "Allowing host $CLIENT" +echo "$CLIENT" >> "$CONFIGPATH/allowed_hosts" + +echo +echo +echo "YOU need to copy the client certificate to" +echo "$CONFIGPATH/allowed_clients. For example, using:" +echo +echo " $ scp $CLIENT:/etc/kxc/cert.pem $CONFIGPATH/allowed_clients" +echo diff --git a/scripts/systemd/kxd.service b/scripts/systemd/kxd.service new file mode 100644 index 0000000..42038c3 --- /dev/null +++ b/scripts/systemd/kxd.service @@ -0,0 +1,10 @@ +[Unit] +Description = Key exchange daemon + +[Service] +EnvironmentFile = /etc/default/kxd +ExecStart = /usr/bin/kxd $OPTS +Type = simple + +[Install] +WantedBy = multi-user.target diff --git a/tests/.pylintrc b/tests/.pylintrc new file mode 100644 index 0000000..4ef871e --- /dev/null +++ b/tests/.pylintrc @@ -0,0 +1,8 @@ + +[MESSAGES CONTROL] +disable=missing-docstring, too-many-public-methods, fixme + +[REPORTS] +output-format=colorized +reports=no + diff --git a/tests/run_tests b/tests/run_tests new file mode 100755 index 0000000..8d7a98b --- /dev/null +++ b/tests/run_tests @@ -0,0 +1,328 @@ +#!/usr/bin/env python + +""" +Tests for kxd and kxc +--------------------- + +This file contains various integration and validation tests for kxc and kxd. + +It will create different test configurations and run the compiled server and +client under various conditions, to make sure they behave as intended. +""" + +# NOTE: Please run "pylint --rcfile=.pylintrc run_tests" after making changes, +# to make sure the file has a reasonably uniform coding style. + + +import httplib +import os +import platform +import shutil +import socket +import ssl +import subprocess +import tempfile +import time +import unittest + + +############################################################ +# Test infrastructure. +# +# These functions and classes are used to make the individual tests easier to +# write. For the individual test cases, see below. + +# Path to our built binaries; used to run the server and client for testing +# purposes. +BINS = os.path.abspath( + os.path.dirname(os.path.realpath(__file__)) + "/../out") + +DEVNULL = open("/dev/null", "w") + +TEMPDIR = "/does/not/exist" + + +def setUpModule(): # pylint: disable=invalid-name + if not os.path.isfile(BINS + "/kxd"): + raise RuntimeError("kxd not found at " + BINS + "/kxd") + if not os.path.isfile(BINS + "/kxc"): + raise RuntimeError("kxc not found at " + BINS + "/kxc") + + global TEMPDIR # pylint: disable=global-statement + TEMPDIR = tempfile.mkdtemp(prefix="kxdtest-") + + +def tearDownModule(): # pylint: disable=invalid-name + # Remove the temporary directory only on success. + # Be extra paranoid about removing. + # TODO: Only remove on success. + if len(TEMPDIR) > 10 and not TEMPDIR.startswith("/home"): + shutil.rmtree(TEMPDIR) + + +def gen_certs(path): + subprocess.check_call( + ["openssl", "genrsa", "-out", "%s/key.pem" % path, "2048"], + stdout=DEVNULL, stderr=DEVNULL) + subprocess.check_call( + ["openssl", "req", "-new", "-x509", "-batch", + "-subj", "/organizationalUnitName=kxd-tests:%s@%s" % ( + os.getlogin(), platform.node()), + "-key", "%s/key.pem" % path, + "-out", "%s/cert.pem" % path], + stdout=DEVNULL, stderr=DEVNULL) + + +class Config(object): + def __init__(self): + # Note we create this temporary path but never delete it. + # This is intentional, as we rather leave a test file around, than + # risk a bug doing a harmful recursive delete. + self.path = tempfile.mkdtemp(prefix="config-", dir=TEMPDIR) + print "Using temporary path", self.path + + def gen_certs(self): + gen_certs(self.path) + + def cert_path(self): + return self.path + "/cert.pem" + + def key_path(self): + return self.path + "/key.pem" + + def cert(self): + return open(self.path + "/cert.pem").read() + + +class ServerConfig(Config): + def __init__(self): + Config.__init__(self) + self.keys = {} + + def new_key(self, name, allowed_clients=None, allowed_hosts=None): + self.keys[name] = os.urandom(1024) + key_path = self.path + "/data/" + name + "/" + if not os.path.isdir(key_path): + os.makedirs(key_path) + open(key_path + "key", "w").write(self.keys[name]) + + if allowed_clients is not None: + cfd = open(key_path + "/allowed_clients", "a") + for cli in allowed_clients: + cfd.write(cli) + + if allowed_hosts is not None: + hfd = open(key_path + "/allowed_hosts", "a") + for host in allowed_hosts: + hfd.write(host + "\n") + + +class ClientConfig(Config): + def __init__(self): + Config.__init__(self) + self.gen_certs() + + def call(self, server_cert, url): + args = [BINS + "/kxc", + "--client_cert=%s/cert.pem" % self.path, + "--client_key=%s/key.pem" % self.path, + "--server_cert=%s" % server_cert, + url] + return subprocess.check_output(args, stderr=subprocess.STDOUT) + + +def launch_daemon(cfg): + args = [BINS + "/kxd", + "--data_dir=%s/data" % cfg, + "--key=%s/key.pem" % cfg, + "--cert=%s/cert.pem" % cfg, + "--logfile=%s/log" % cfg] + return subprocess.Popen(args) + + +class TestCase(unittest.TestCase): + def setUp(self): + self.server = ServerConfig() + self.server.gen_certs() + self.daemon = launch_daemon(self.server.path) + + self.client = ClientConfig() + + # Wait for the server to start accepting connections. + deadline = time.time() + 5 + while time.time() < deadline: + try: + socket.create_connection(("localhost", 19840), timeout=5) + break + except socket.error: + continue + else: + self.fail("Timeout waiting for the server") + + def tearDown(self): + self.daemon.kill() + + # pylint: disable=invalid-name + def assertClientFails(self, url, regexp, client=None): + if client is None: + client = self.client + + try: + client.call(self.server.cert_path(), url) + except subprocess.CalledProcessError as err: + self.assertRegexpMatches(err.output, regexp) + else: + self.fail("Client call did not fail as expected") + + +############################################################ +# Test cases. +# + +class Simple(TestCase): + """Simple test cases for common (mis)configurations.""" + + def test_simple(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert()], + allowed_hosts=["localhost"]) + key = self.client.call(self.server.cert_path(), "kxd://localhost/k1") + self.assertEquals(key, self.server.keys["k1"]) + + def test_404(self): + self.assertClientFails("kxd://localhost/k1", "404 Not Found") + + def test_no_client_cert(self): + self.server.new_key("k1", allowed_hosts=["localhost"]) + self.assertClientFails("kxd://localhost/k1", + "403 Forbidden.*No allowed certificate found") + + def test_host_not_allowed(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert()], + allowed_hosts=[]) + self.assertClientFails("kxd://localhost/k1", + "403 Forbidden.*Host not allowed") + + def test_not_allowed(self): + self.server.new_key("k1") + # We don't restrict the reason of failure, that's not defined in this + # case, as it could be either the host or the cert that are validated + # first. + self.assertClientFails("kxd://localhost/k1", "403 Forbidden") + + +class Multiples(TestCase): + """Tests for multiple clients and keys.""" + + def setUp(self): + TestCase.setUp(self) + self.client2 = ClientConfig() + + def test_two_clients(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert(), self.client2.cert()], + allowed_hosts=["localhost"]) + key = self.client.call(self.server.cert_path(), "kxd://localhost/k1") + self.assertEquals(key, self.server.keys["k1"]) + + key = self.client2.call(self.server.cert_path(), "kxd://localhost/k1") + self.assertEquals(key, self.server.keys["k1"]) + + def test_one_client_allowed(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert()], + allowed_hosts=["localhost"]) + key = self.client.call(self.server.cert_path(), "kxd://localhost/k1") + self.assertEquals(key, self.server.keys["k1"]) + + self.assertClientFails("kxd://localhost/k1", + "403 Forbidden.*No allowed certificate found", + client=self.client2) + + def test_many_keys(self): + keys = ["a", "d/e", "a/b/c", "d/"] + for key in keys: + self.server.new_key(key, + allowed_clients=[self.client.cert(), self.client2.cert()], + allowed_hosts=["localhost"]) + + for key in keys: + data = self.client.call(self.server.cert_path(), + "kxd://localhost/%s" % key) + self.assertEquals(data, self.server.keys[key]) + + data = self.client2.call(self.server.cert_path(), + "kxd://localhost/%s" % key) + self.assertEquals(data, self.server.keys[key]) + + self.assertClientFails("kxd://localhost/a/b", "404 Not Found") + + +class TrickyRequests(TestCase): + """Tests for tricky requests.""" + + def test_no_local_cert(self): + conn = httplib.HTTPSConnection("localhost", 19840) + try: + conn.request("GET", "/v1/") + except ssl.SSLError as err: + self.assertRegexpMatches(str(err), "alert bad certificate") + else: + self.fail("Client call did not fail as expected") + + def test_dotdot(self): + conn = httplib.HTTPSConnection("localhost", 19840, + key_file=self.client.key_path(), + cert_file=self.client.cert_path()) + conn.request("GET", "/v1/a/../b") + response = conn.getresponse() + + # Go's http server intercepts these and gives us a 301 Moved + # Permanently. + self.assertEquals(response.status, 301) + + def test_server_cert(self): + rawsock = socket.create_connection(("localhost", 19840)) + sock = ssl.wrap_socket(rawsock, + keyfile=self.client.key_path(), + certfile=self.client.cert_path()) + + # We don't check the cipher itself, as it depends on the environment, + # but we should be using > 128 bit secrets. + self.assertTrue(sock.cipher()[2] > 128) + + server_cert = ssl.DER_cert_to_PEM_cert( + sock.getpeercert(binary_form=True)) + self.assertEquals(server_cert, self.server.cert()) + + +class BrokenServerConfig(TestCase): + """Tests for a broken server config.""" + + def test_broken_client_certs(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert()], + allowed_hosts=["localhost"]) + + # Corrupt the client certificate. + cfd = open(self.server.path + "/data/k1/allowed_clients", "r+") + for _ in range(4): + cfd.readline() + cfd.write('+/+BROKEN+/+') + cfd.close() + + self.assertClientFails("kxd://localhost/k1", + "500 Internal Server Error.*Error loading certs") + + def test_missing_key(self): + self.server.new_key("k1", + allowed_clients=[self.client.cert()], + allowed_hosts=["localhost"]) + + os.unlink(self.server.path + "/data/k1/key") + self.assertClientFails("kxd://localhost/k1", "404 Not Found") + + +if __name__ == "__main__": + unittest.main()