DNSSEC signing with Knot DNS and YubiKey

Knot DNS 2.1 introduced support for DNSSEC signing using PKCS #11. PKCS #11 (also called Cryptoki) is a standard interface to access various Hardware Security Modules (HSM). Such devices are usually used to improve protection of private key material. The interface is rather flexible and gives the HSM vendors huge amount of freedom, which unfortunately makes its use a bit tricky. There are often surprising differences between individual implementations.

I had a chance to test the server interoperability with distinct PKCS #11 capable devices since the Knot DNS 2.1 release. The list of them can be found in the documentation. Out of my curiosity, I’ve also tried to sign a zone with YubiKey NEO, which is a small USB token I am carrying on my key chain and which is not exactly designed for this purpose.

In this blog post, I’ll show you how to make Knot DNS sign a zone with YubiKey NEO using the PIV applet. But before we get on bending Knot DNS, I’ll just quickly explain how PKCS #11 works.

DNSSEC Signing with PKCS #11

PKCS #11 is just an interface. The token vendor will give you an actual implementation of the interface as a dynamic library. The application will then load the library and use it to talk to the device.

The communication with the token is stateful. It is initiated by opening a session, performing requested operations, and terminating the session afterwards. As for the requested operations, it depends on what we want to do with the token and on what the token really supports. Some set of operations should be always available, like listing objects stored on the token. And some operations are implemented optionally, like the CKM_ECDSA mechanism for ECDSA signing and verification.

If we want to sign a zone in DNSSEC and have the signing key on a token, the communication may happen in the following steps:

  1. Open a session to the token.
  2. Authenticate to the token using a PIN.
  3. Get a handle of the private key object.
  4. For each RR set to be signed: Compute the hash of the RR set, send it to the token, and retrieve a signature.
  5. Close the session.

Usually, the private key object is marked as sensitive (CKA_SENSITIVE) and never extractable (CKA_NEVER_EXTRACTABLE). This means that the device physically protects the key and the sensitive material will never get to the application, which significantly increases the security of the key. On the other side, this is probably gonna be much slower than computing the signature on a CPU.

Setting Up YubiKey

YubiKey NEO is not a general purpose HSM. The PIV applet has only four slots and each of them can store only one private key object for one specific purpose. So we will practically misuse one of the slots to store the DNSSEC signing key.

I’ve tried with slots 9a (intended for PIV Authentication) and 9e (intended for Card Authentication). Both slots will work. I am not sure about the other two as there might be a difference on the PIN requirement. Anyway, I am going to use slot 9e in this tutorial because I use the other one for SSH authentication.

To set up the token, we will use the yubico-piv-tool. Be extremely careful when following these steps especially if you are using a production device. Try to understand the commands before pasting them into the terminal.

First, make sure the slot is empty. You should get an output similar to the following one:

% yubico-piv-tool -s 9e -a status 
CHUID:  ...
CCC:    No data available
PIN tries left: 10

Now, we need to generate the signing key. I’ve decided to generate an ECCP256 key because I intend to sign the zone with the ECDSAP256SHA256 DNSSEC algorithm. The next command generates the key pair on the token and saves the the public part into a file:

% yubico-piv-tool -s 9e -a generate -A ECCP256 -o token.pem
Successfully generated a new private key.
% cat token.pem
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUgGYfiNse1qT4GIojG0VGcHByLWq
ByiafQ8Yt7/Eit2hCPYYcyiE+TX8HP8al/SzCnaA8nOpAkqFgPCI26ydqw==
-----END PUBLIC KEY-----

The remaining steps will be very specific to YubiKey NEO. The PIV applet requires us to have a certificate with the public key we will use in the slot as well. The certificate is not needed in practice and can be even self-signed. So let’s make the PIV applet happy [sic]:

% yubico-piv-tool -s 9e -a selfsign-certificate -S "/CN=acme.test/" 
  -i token.pem -o token.crt
Successfully generated a new self signed certificate.

The next thing to do is to load the certificate into the token:

% yubico-piv-tool -s 9e -a verify -a import-certificate -i token.crt
Successfully imported a new certificate.

And finally check that the slot was initialized correctly:

% yubico-piv-tool -s 9e -a status            
CHUID:  ...
CCC:    No data available
Slot 9e:    
    Algorithm:  ECCP256
    Subject DN: CN=acme.test
    Issuer DN:  CN=acme.test
    Fingerprint:    97dda8a441a401102328ab6ed4483f08bc3b4e4c91abee8a6e144a6bb07a674c
    Not Before: May 27 12:10:10 2016 GMT
    Not After:  May 27 12:10:10 2017 GMT
PIN tries left: 10

Converting PEM to DNSKEY

Normally with Knot DNS and PKCS #11, the keymgr utility would generate a key pair on the token and would store the public key in the DNSSEC format in the KASP database. Unfortunately we couldn’t have used keymgr with Yubikey NEO and therefore we have to perform the public key format conversion manually.

The public key we have is in the PEM (PKCS #8) format and we need it in the DNSSEC format. The conversion is rather simple for ECDSA because the encoding used in PKCS #8 is very similar to the encoding used in DNSSEC. The public key in ECDSA is a point on an elliptic curve. In DNSSEC, the point is represented in uncompressed form as a bit string. Luckily for us, this is roughly a subset of possible encodings in PKCS #8.

First, let’s use openssl to retrieve the public key parameters:

% openssl ec -text -noout -pubin -conv_form uncompressed -in token.pem
Private-Key: (256 bit)
pub: 
    04:52:01:98:7e:23:6c:7b:5a:93:e0:62:28:8c:6d:
    15:19:c1:c1:c8:b5:aa:07:28:9a:7d:0f:18:b7:bf:
    c4:8a:dd:a1:08:f6:18:73:28:84:f9:35:fc:1c:ff:
    1a:97:f4:b3:0a:76:80:f2:73:a9:02:4a:85:80:f0:
    88:db:ac:9d:ab
ASN1 OID: prime256v1
NIST CURVE: P-256

The pub value represents the public key (i.e. the point on the curve). The length of the value should be 65 bytes. The first byte 0x04 asserts that the uncompressed form of the point is used, the next 32 bytes represent the X coordinate of the point, and the remaining 32 bytes represent the Y coordinate. So for DNSSEC, we just simply drop the first byte of the pub value.

All above and the final conversion to Base64 can be done with the following shell magic:

% openssl ec -text -noout -pubin -conv_form uncompressed -in token.pem | 
  grep "^ " | tr -d ':[[:space:]]' | cut -c 3- | xxd -p -r | base64
UgGYfiNse1qT4GIojG0VGcHByLWqByiafQ8Yt7/Eit2hCPYYcyiE+TX8HP8al/SzCnaA8nOpAkqF
gPCI26ydqw==

The resulting value is the Public Key field value of the DNSKEY record. Altogether, the DNSKEY may look like this:

acme.test. DNSKEY 256 3 13 UgGYfiNse1qT4GIojG0VGcHByLWqByiafQ8Yt7/Eit2hCPYYcyiE+TX8HP8al/SzCnaA8nOpAkqFgPCI26ydqw==

Configuring KASP

Finally we are getting to server configuration. We need to set up the KASP database and make some manual tweaks in it. The KASP database stores configuration for DNSSEC signing and also some state data. If you started with blank configuration, you have to initialize the database:

% cd /var/lib/knot/keys
% keymgr init

The next thing to do is to configure the PKCS #11 private key store. We can use ether the opensc-pkcs11.so or libykcs11.so module. Both modules use PCSC Lite backend and CCID transport to talk to the YubiKey. The first module is provided by OpenSC, the second one by Yubico. I’ve already tried with the first one and it worked so I’ll go with the second one this time.

We need to get the PKCS #11 URL of the token. Let’s use p11tool for this purpose:

% p11tool --provider /usr/lib64/libykcs11.so.1 --list-token-urls
pkcs11:model=YubiKey%20NEO;manufacturer=Yubico;serial=1234;token=YubiKey%20PIV

Now we can configure the private key store in the KASP database. The configuration of the PKCS #11 backend consists of the token URL and a path to the provider library. The URL can be simplified a little bit as far as the token identification is unique. But it is necessary to add the pin-value attribute with the user PIN. In my case, the resulting command will be:

% keymgr keystore add yubikey backend pkcs11 config \
  "pkcs11:model=YubiKey%20NEO;token=YubiKey%20PIV;pin-value=123456 /usr/lib64/libykcs11.so.1"

Then we will add a new signing policy which with manual key management and the just created keystore:

% keymgr policy add yubikey manual true keystore yubikey

Next we will create a record for the zone and assign it to the new policy:

% keymgr zone add acme.test policy yubikey

Now comes the tweaking part. We want to add the public key record into the KASP database. Knot DNS uses a key ID to refer to an object on a token, and the key ID is the last missing information we need. To retrieve the ID, we can use p11tool again:

% p11tool --provider /usr/lib64/libykcs11.so.1 --login --list-privkeys "pkcs11:model=YubiKey%20NEO"

Object 0:
    URL: pkcs11:model=YubiKey%20NEO;manufacturer=Yubico;serial=1234;token=YubiKey%20PIV;id=%01;object=Private%20key%20for%20Card%20Authentication%00;type=private
    Type: Private key
    Label: Private key for Card Authentication
    ID: 01

And finally let’s put all pieces together. Open the zone_acme.com.json file and add a new entry for the key. After that, the content of the file should look about this:

{
  "policy": "yubikey",
  "keys": [
    {
      "id": "01",
      "algorithm": 13,
      "public_key": "UgGYfiNse1qT4GIojG0VGcHByLWqByiafQ8Yt7/Eit2hCPYYcyiE+TX8HP8al/SzCnaA8nOpAkqFgPCI26ydqw==",
      "ksk": false
    }
  ]
}

Patching and Running Knot DNS

Knot DNS is very restrictive about key IDs. In particular, it requires the key ID to have some certain length. It is thus necessary to relax these limitations. This will be probably the most painful step of this tutorial as it requires recompilation and I’m just going to assume that you know how to do it.

Apply the following patch which removes the check on the key ID length:

diff --git a/src/dnssec/lib/keyid.c b/src/dnssec/lib/keyid.c
index 0c02026..9a36667 100644
--- a/src/dnssec/lib/keyid.c
+++ b/src/dnssec/lib/keyid.c
@@ -31,11 +31,12 @@ bool dnssec_keyid_is_valid(const char *id)
                return false;
        }

-       if (strlen(id) != DNSSEC_KEYID_SIZE) {
+       size_t len = strlen(id);
+       if (len == 0) {
                return false;
        }

-       for (int i = 0; i < DNSSEC_KEYID_SIZE; i++) {
+       for (int i = 0; i < len; i++) {
                if (!isxdigit((int)id[i])) {
                        return false;
                }
@@ -51,7 +52,8 @@ void dnssec_keyid_normalize(char *id)
                return;
        }

-       for (size_t i = 0; i < DNSSEC_KEYID_SIZE; i++) {
+       size_t len = strlen(id);
+       for (size_t i = 0; i < len; i++) {
                assert(id[i] != '' && isxdigit((int)id[i]));
                id[i] = tolower((int)id[i]);
        }

And recompile Knot DNS. After that, enable automatic signing in the server configuration. You can get inspired by this configuration file snippet:

template:
  - id: default
    storage: /var/lib/knot
    kasp-db: /var/lib/knot/keys

zone:
  - domain: acme.test
    dnssec-signing: true

Fire up the server and see what happens:

% knotd -c knot.conf
...
info: starting server
info: [acme.test] zone loader, semantic check, completed
info: [acme.test] DNSSEC, loaded key, tag 17581, algorithm 13, KSK no, ZSK yes, public yes, active yes
info: [acme.test] DNSSEC, Single-Type Signing scheme enabled, algorithm '13'
info: [acme.test] DNSSEC, signing started
info: [acme.test] DNSSEC, successfully signed
info: [acme.test] DNSSEC, next signing on 2016-06-03T14:51:36
info: [acme.test] loaded, serial 15
info: server started in the foreground, PID 28342
info: [acme.test] zone file updated, serial 14 -> 15
...

The zone was signed. Now check that the server is responding correctly:

% kdig acme.test DNSKEY
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 11751
;; Flags: qr aa rd; QUERY: 1; ANSWER: 1; AUTHORITY: 0; ADDITIONAL: 0

;; QUESTION SECTION:
;; acme.test.               IN  DNSKEY

;; ANSWER SECTION:
acme.test.              60  IN  DNSKEY  256 3 13 UgGYfiNse1qT4GIojG0VGcHByLWqByiafQ8Yt7/Eit2hCPYYcyiE+TX8HP8al/SzCnaA8nOpAkqFgPCI26ydqw==

;; Received 107 B
;; Time 2016-05-27 14:52:42 CEST
;; From 127.0.0.1@53000(UDP) in 0.1 ms

Great! It works. As a proof of concept, though.

Author:

Leave a comment