TPM Secured GPG Keys which never touch the hard disk

Background

gpg >= v2.3 has supported TPMs natively. This support works totally fine for some applications via the keytotpm function.

However this function is very specific in the operation it performs: the keytotpm function encrypts the key you have on-disk, in place, with the TPM's RSA2048 key. What this ensures is that if your disk and the computer's TPM are separated, the key is effectively unreadable. In fact if the key file is used anywhere but your computer, it is also unusable.

Combined with a password on your keyring, this is excellent protection against many attacks.

It does have one significant drawback though: (1) the key material, at some point, exists in an unprotected form on the computer.

Situation

Our situation then is that we would like to generate a TPM-backed key where the private key is never exposed at all. Essentially our presumed adversary might collect any unsecured key material on the PC at any point.

This isn't a super-practical scenario in a lot of cases: i.e. with this much control, you could also just use the key by whatever compromise and key-logging you deployed.

But there are some practical advantages here: we can generate and sign this key using another key by more trustworthy means, such as GPG keys held on a Yubikey or similar device.

It's also a practical way to grant access to key material for multiple processes - i.e. if we wanted to run a bunch of CI/CD runner processes where attestations of the runner identity were needed by client code - since all cryptography will be done by the TPM, and the key will never leave the TPM even to be used, we can use this as a reasonable proof that whatever else was done, it was done on a specific runner.

Solution

Our solution is obvious: we want to create a GPG key with private key material which is stored on the TPM and can never leave it. The tools to do this exist. We will do this by leveraging the PKCS11 system, and the various interop tools to run it.

Step 1: Initialize a new key in the TPM

We use tpm2_ptool (apt install libtpm2-pks11-tools) to setup a new token in the TPM:

# Initialize a new store. The store retains some data, but will not contain key material.
tpm2_ptool
# Prompt user for pin numbers - see below for explanation
read -r -s -p "Enter User PIN: " uspin
echo
read -r -s -p "Enter Mgmt PIN: " sopin
echo

# Add a new token to the store
tpm2_ptool addtoken --pid=1 --label="gpg" --userpin=$uspin --sopin=$sopin

# Add a new key to the token - this generates the private key
tpm2_ptool addkey --label="gpg" --key-label="gpg" --userpin=$uspin --algorithm=rsa2048

A note on pin numbers: you can leave the "User PIN" blank - this will enable using the key without prompting. Be aware that the consequences of this are that anyone with access to the motherboard of your PC though can use but not copy your key. This extends to multi-user systems where anyone in the tss group (on Ubuntu at least) will be able to do the same.

In a practical sense you should set the user pin to a short word - it's generally 4-6 characters, but they can be any characters. When you use the key, you enter the pin, and if someone tries to brute force it then the TPM will lockout the pin after a certain number of attempts. This is what the "Mgmt PIN" is for - which can be used to unlock a locked user PIN but not use the key the User PIN protects.

All of this is inherited from smartcard logic. In a practical sense, it would be safe to stick the User PIN in your system keyring, unlocked by your user account at boot, and not think about it.

Step 2: Generate and Sign an X509 certificate the key

To get this all working, we're leveraging a couple of different tools: apt install libtpm2-pkcs11-1 libtpm2-pkcs11-tools gnupg-pkcs11-scd gnutls-bin libnss3-tools p11-kit

You want to run p11-kit list-modules which will give you an output (on Ubuntu 24.04) which looks something like this:

$ p11-kit list-modules
module: p11-kit-trust
    path: /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so
    uri: pkcs11:library-description=PKCS%2311%20Kit%20Trust%20Module;library-manufacturer=PKCS%2311%20Kit
    library-description: PKCS#11 Kit Trust Module
    library-manufacturer: PKCS#11 Kit
    library-version: 0.25
    token: System Trust
        uri: pkcs11:model=p11-kit-trust;manufacturer=PKCS%2311%20Kit;serial=1;token=System%20Trust
        manufacturer: PKCS#11 Kit
        model: p11-kit-trust
        serial-number: 1
        hardware-version: 0.25
        flags:
              write-protected
              token-initialized
module: opensc-pkcs11
    path: /usr/lib/x86_64-linux-gnu/pkcs11/opensc-pkcs11.so
    uri: pkcs11:library-description=OpenSC%20smartcard%20framework;library-manufacturer=OpenSC%20Project
    library-description: OpenSC smartcard framework
    library-manufacturer: OpenSC Project
    library-version: 0.25
    token: OpenPGP card (User PIN)
        uri: pkcs11:model=PKCS%2315%20emulated;manufacturer=Yubico;serial=000618103012;token=OpenPGP%20card%20%28User%20PIN%29
        manufacturer: Yubico
        model: PKCS#15 emulated
        serial-number: 000618103012
        hardware-version: 3.4
        firmware-version: 3.4
        flags:
              rng
              login-required
              user-pin-initialized
              token-initialized
    token: OpenPGP card (User PIN (sig))
        uri: pkcs11:model=PKCS%2315%20emulated;manufacturer=Yubico;serial=000618103012;token=OpenPGP%20card%20%28User%20PIN%20%28sig%29%29
        manufacturer: Yubico
        model: PKCS#15 emulated
        serial-number: 000618103012
        hardware-version: 3.4
        firmware-version: 3.4
        flags:
              rng
              login-required
              user-pin-initialized
              token-initialized
module: tpm2_pkcs11
    path: /usr/lib/x86_64-linux-gnu/pkcs11/libtpm2_pkcs11.so
    uri: pkcs11:library-description=TPM2.0%20Cryptoki;library-manufacturer=tpm2-software.github.io
    library-description: TPM2.0 Cryptoki
    library-manufacturer: tpm2-software.github.io
    library-version: 1.9
    token: 
        uri: pkcs11:model=AMD%00%00%00%00%00%00%00%00%00%00%00%00%00;manufacturer=AMD;serial=0000000000000000;token=
        manufacturer: AMD
        model: AMD
        serial-number: 0000000000000000
        hardware-version: 1.38
        firmware-version: 3.37
        flags:
              rng
              login-required

The line we want is module tpm2_pkcs11 since that gives us the filepath to the TPM PKCS module. Run the following to store it:

tpm_lib="$(p11-kit list-modules | grep -A1 'module: tpm2_pkcs11' | tail -n1 | sed 's/^\s*//g' | cut -d' ' -f2)"
# Check this worked on your machine.
echo "$tpm_lib"

Our next step is to get the URIs we need - make sure you're in the same terminal session with all the environment variables we set above and run:

token_uri="$(p11tool --list-token-urls | grep token=gpg)"
private_uri="$(p11tool --list-privkeys --login --only-urls --set-pin=${uspin} ${private_uri})"

# Check the private key is accessible - this should display success
p11tool --test-sign --login --set-pin=${uspin} "${private_uri}"

What this has done is recorded the URLs we need from the PKCS libraries which represent the public and private keys in the TPM - access which is being provided by the tpm2_pkcs11 module.

At this point many guides refer to using OpenSSL to generate and sign certificates - I could not find a way to make this work on Ubuntu 22.04 or 24.04, hitting errors with the OpenSSL pkcs11 engine everytime. The working solution was to use certtool which is provided by gnutls-bin.

We'll be using it in self-signed mode: you could obviously use a CA and setup a more complicated system etc. but all of this is just to support GPG recognizing and being able to issue keys from the TPM - which the X509 story is a pre-requisite, but unused otherwise.

So firstly run this to setup your common name:

read -r -p "Enter Name (firstname lastname):" name
read -r -p "Enter Email (user@domain):" email

These are superfluous in many ways - we're just constructing a CN we'll recognized when importing later. In my application the goal was a GPG key which identified me and would be trust-signed by the keys on my YubiKey, which in turn would be signed by an offline master key (or one stored with a strong password and TPM backed on my main machine, with a paper backup somewhere).

We now need to emit a template file for the certificate (there's a lot you can do here - again, look it up if you want to use more X509 features):

template_ini="$(mktemp template.XXXXXXX.ini)"
cat << EOF > "$template.ini"
cn = "${name}"
serial = $(date --utc +%Y%m%d%H%M%S)
expiration_days = 365
email = "${email}"
signing_key
encryption_key
cert_signing_key
EOF

I'm not sure if these are optimal parameters for the application, but again, the GPG key will be totally independent of this certificate once created - this is a handle to use the TPM's key material.

Generate the new self-signed certificate:

GNUTLS_PIN="${uspin}" certtool --generate-self-signed --template "$template_ini" \
    --load-private "${private_uri}" --outfile "${name}.crt"

And then we can add the certificate to the TPM:

tpm2_ptool addcert --label=gpg --key-label=gpg "${name}.crt"

And that's a measure of success! At this point we have a certificate and key loaded securely onto the system TPM, protected by a PIN, and the private key is completely unexportable (barring your thoughts on either a backdoor existing, or someone decapping the chip and reading the bits out of the secure enclave at enormous time and expense - but we'll see GPG provides us a mitigation for that too).

Step 3: Configure GPG to use gnupg-pkcs-scd

gnupg-pkcs-scd is a deplacement scdaemon (Smart Card Daemon) for gpg which allows interfacing with the PKCS11 stack as a source of smart cards. Specifically - it emulates the OpenPGP card standard for GPG. This is very useful if you're using PIV mode on a YubiKey, CAC cards or other enterprisey things - it's also useful for what we're doing here.

So first - create the configuration file:

cat << EOF > "$HOME/.gnupg/gnupg-pkcs11.conf"
provider tpm
provider-tpm-library "${tpm_lib}"
EOF

Note: you can have multiple providers and gnupg-pkcs-scd will merge them and present them as a single smart card to GPG: this is super useful if you have say, a YubiKey in PIV mode you also want to use - checkout the man page for more information.

Configure GPG to use the daemon:

cat << EOF >> "$HOME/.gnupg/gpg-agent.conf"
scdaemon-program $(command -v gnupg-pkcs11-scd)
pinentry-program $(command -v pinentry-gnome3)
EOF

Restart the daemon:

systemctl --user restart gpg-agent.service
gpg --card-status

Step 4: Import the keys

The instructions here aren't great because GPG is very prompt driven (fairly on the assumption this is an important thing you're doing - but it can be scripted I just haven't yet).

Firstly you need to get a listing of your key-grips from the agent - note "KEY-FRIEDLY" is not my spelling mistake, the protocol really writes that:

gpg-agent --server gpg-connect-agent << EOF 2>/dev/null | grep KEY-FRIEDNLY
SCD LEARN
EOF

If you only have a few keys then you can get the exact key grip with this command:

gpg-agent --server gpg-connect-agent << EOF 2>/dev/null | grep KEY-FRIEDNLY | grep gpg | cut -d' ' -f3
SCD LEARN
EOF

But do check that looks sensible.

Now you just need to import the key: GPG does promise to do a "hands off" import with Option 14 in the following command, but it will fail to detect any key-usages. So run the following, and select Option 13 when prompted - then select the keygrip you found above.

You'll be prompted to select usages - you likely want the line to read Current allowed actions: Sign Certify Encrypt. You can customize according to intended use, but for RSA that list is fine. You might want to add Authenticate if you want to use this key for say, SSH authorization. See this InfoSec Stack Overflow post for an interesting discussion about this.

The punchline is that because the underlying key is RSA, it can soundly to everything we need - the Sign/Certify distinction is left over from the days of DSA keys, which can't do things like encryption.

One final thing is ideally you should set your key expiry to 364 days: because above we set the X509 certificate to expire after 365 days. Again: the certificate doesn't actually affect anything GPG wise since we just use it to get a keygrip to access the key - but you should set expiries (and keep them reasonably short) and if we're going to do that, we might as well line things up (plus if you do start using the X509 part, then it'll be sensible).

Conclusions

I sank way too many hours into this chasing down a lot of commands which just didn't work. As far as I know, on Ubuntu 24.04, these commands do and do have the intended effect. If I've missed any packages for commands here, use apt-file or similar to find the necessary packages and install them.

Cryptographic things like this alway eat time because once setup they will get out of your way but until they're setup there's quite a lot of "annoying to undo" things.

Should you do this? Probably not - the use case of protecting the key from global surveillance like this is pretty low. There are some other wrinkles: i.e. it would take some work to get "the same" GPG key back out of the TPM if you lost the certificate entry in the daemon itself - but there's no secret key in it, so you can back that up however you want without worry.

For most users keytotpm is a much better and more versatile solution. But this is also a decent entry into the world of TPM key manipulation and those libaries can be used for other things (i.e. OpenVPN or SSH).