Unlocking LUKS2 with X509 certificate on Nitrokey Storage

Published on

This post describes the procedure to unlock LUKS2 partition using X509 certificate stored on Nitrokey Storage.

Intro #

I have a Nitrokey Storage and it is an extremely nifty device. In my latest round of the “let’s come up with yet another cool use for it” game, I have discovered that systemd, starting with version 248, can use pkcs#11 certificates to unlock LUKS2 partitions.

Starting with Nitrokey configured with 3 GPG keys, I have added X509 certificate and configured systemd-cryptsetup to unlock a partition if Nitrokey is inserted.

This post is about the initial setup and can be applied to either a partition that should be unlocked during or after boot. For boot-related configuration instructions, see the next post.

Disclaimer/warning #

Please implement the following procedure with extreme caution. If a wrong command is issued, you may wipe keys from your Nitrokey or make your LUKS-encrypted partition inaccessible. In a word – introduce a lot of unnecessary headache into your life.

Keep backups of:

Also, using the language from the GPL license: this post is written in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

Overview #

The procedure can be divided into two parts:

  1. Nitrokey setup: create and record X509 certificate
  2. LUKS setup

Nitrokey setup #

Nitrokey Storage has slots for 3 GPG keys and 1 X509 certificate. When initially setting up the token, you can do it either using OpenPGP or S/MIME. According to Nitrokey docs, these approaches are mutually exclusive, but one can create the certificate in a way that will use one of the existing GPG keys as the certificate’s private key.

To do that, you will need a set of tools (needed Gentoo packages are provided in parentheses):

First, let’s generate the certificate. Insert the Nitrokey and determine the RFC7512 URI of the key:

~# p11tool --list-all

In out case we will be using the one without %28sig%29, I will refer to it as $TOKEN_URI. I will be using the Authentication key as the private one. We will need to determine the URI of the private key we want to use. To do that, use p11tool again:

~# p11tool --outfile=/tmp/p11_output --login --list-all $TOKEN_URI

This output will have the following information:

Object 2:
        URL: pkcs11:model=PKCS%2315%20emulated;manufacturer=ZeitControl;...User%20PIN%29;id=%03;object=Authentication%20key;type=private
        Type: Private key (RSA-...)
        Label: Authentication key
        ID: 03

We will need the URL field, to which I will refer to as $KEY_URI

Now we have everything we need to generate either the self-signed certificate or a certificate signing request (CSR) to be sent to a Certificate Authority for signing. Source of this part – OpenSC wiki.

~# openssl req -engine pkcs11 -new -key "$KEY_URI" -keyform engine -x509 -out cert.pem -text

Follow the certificate creation wizard, answering prompts.

Once the certificate is ready (either a self-signed or a CA-signed one), we can record it on the Nitrokey. Run pkcs15-tool to get the ID and Auth ID fields that we will specify to properly associate the certificate with its private key.

~# pkcs15-tool --list-keys
Private RSA Key [Authentication key]
        Auth ID        : 02
        ID             : 03

Now, run the pkcs15-init command to record the certificate. We will be reusing the existing key, so there is no need for any flags that erase the current public/private keys.

~# pkcs15-init --store-certificate cert.pem --auth-id $AUTH_ID --id $ID --format pem

Replace $AUTH_ID and $ID with the output of pkcs15-tool, in our case – they are 02 and 03 respectively.

Now p11tool should show that there are 4 objects on your Nitrokey. One of them should have “Type” set as “X.509 Certificate”.

Configuring LUKS2 #

Now, onto the second part – configuring the encrypted drive. Systemd provides a utility called systemd-cryptenroll that has a special switch --pkcs11-token-uri. If this switch is set to auto, systemd-cryptenroll will try to guess which token URI to use to lock the partition.

However, in this Nitrokey setup and the current version of systemd, systemd-cryptenroll guesses are wanting, leading to errors when trying to unlock the drive. Most notable one is “Configured private key URI matches multiple keys, refusing.”. So, we will specify the URI by hand. First, let’s find out the URI of the certificate:

~# p11tool --outfile=/tmp/p11_output --login --list-all $TOKEN_URI
                URL: pkcs11:model=PKCS%2315%20emulated;manufacturer=ZeitControl;...User%20PIN%29;id=%03;object=Cardholder%20certificate;type=cert

The “URL” above is the URI of our certificate, $CERT_URI. Now, we can enroll it:

~# systemd-cryptenroll --pkcs11-token-uri="$CERT_URI" /dev/sdXn

Replace /dev/sdXn with the LUKS2 partition. On this step systemd-cryptenroll records the token in a header of the LUKS partition.

If we check the output of cryptsetup luksDump /dev/sdXn, we can see that the token is enrolled:

  0: luks2
  1: luks2
  1: systemd-pkcs11
        Keyslot:  1

We can also take a closer look at the header by running cryptsetup token export –token-id 1 /dev/sdXn | jq

  "type": "systemd-pkcs11",
  "keyslots": [
  "pkcs11-uri": "$CERT_URI",
  "pkcs11-key": "..."

In this case, the randomly generated encrypted key is embedded into LUKS2 header. Then, when systemd-cryptsetup is invoked, with pkcs11-uri set to “auto”, it will read the header and try to use the key from pkcs11-uri to decrypt the key, and then use the key to unlock the drive.

If we try to unlock the partition now, systemd-cryptsetup will complain: “Selected PKCS#11 object is not a private key, refusing.”, which makes sense, since it’s a certificate.

Let’s help systemd-cryptsetup a little and specify the proper private key URI in the header. First, dump the header by running cryptsetup token export -token-id 1 > header.json.

Opening the header.json with your favorite editor, replace the “pkcs11-uri” value with the $KEY_URI of the private key we found earlier, import it back and delete the old token:

~# cryptsetup token import --json-file header.json --token-id $NEW_TOKEN_ID /dev/sdXn
~# cryptsetup token remove --token-id $OLD_TOKEN_ID /dev/sdXn

Replace the $NEW_TOKEN_ID with a new numeric ID and $OLD_TOKEN_ID with the output of cryptsetup luksDump. In our case OLD_TOKEN_ID would be 1.

Now, we can test that the unlocking works:

~# /lib/systemd/systemd-cryptsetup attach luks /dev/sdXn - pkcs11-uri="auto"
Set cipher aes, mode xts-plain64, key size 512 bits for device /dev/disk/by-uuid/65b6de87-2fe1-41f7-b99e-6113bbc8c905.
Automatically discovered security PKCS#11 token 'pkcs11:model=PKCS%2315%20emulated;manufacturer=ZeitControl;...User%20PIN%29;id=%03;object=Authentication%20key;type=private' unlocks volume.
🔐 Please enter PIN for security token 'OpenPGP card (User PIN)' in order to unlock luks: ****************
Successfully logged into security token 'OpenPGP card (User PIN)'.
Successfully decrypted key with security token.

See also #