Morten Linderud

F/OSS Developer, Arch Linux Developer and security team.

ACME device attestation, smallstep and pkcs11: attezt
Mar 21, 2026
12 minutes read

For my personal home infrastructure I’ve been using step-ca to have an internal ACME server for issuing TLS certificates for my .home.arpa domain. I also intended to use this to sign ssh certificates so I could simplify my SSH key setup.

And i really like hardware bound keys.

They solve a very concrete problem where even if someone can extract a signing key from your system, they are effectively useless without access to the hardware they where bound to. This hardware could be something like a yubikey, or another FIDO device. But in 2026 most of our machines have a Trusted Platform Module (TPM) that functions as a free hardware enclave we can use to secure our keys with.

I have written two projects that helps with this for general purpose software. age-plugin-tpm implements a TPM backed encryption keys for the age encryption software, and ssh-tpm-agent that allows you to have ssh keys created by the TPM and utilized through the generic ssh-agent protocol.

Which is why I got super excited when I saw that step-ca actually supports “ACME for device attestation”!

device-attest-01

What step-ca supports is a new ACME challenge called device-attest-01. ACME currently supports several different challenges that enables you to prove the identity of a domain. The two most popular challenges is http-01 that doesn’t an HTTP challenge, and dns-01 that does a DNS challenge.

device-attest-01 proves the identity of a device by using attestation and having a trusted CA sign these statements for authenticity. Others have explained device attestation far better then me, but the general idea is to have some hardware enclave, that we deem trustworthy, which signs statements about the integrity and authenticity of a device. The ACME server then issues a certificate to the device that attests to the identity of this device. That means we can use the TPM to create keys and issue certificates to our machines bound by the identity.

https://datatracker.ietf.org/doc/draft-ietf-acme-device-attest/

Now, the complicated part is that ACME doesn’t do any attestation itself. It relies on a secondary party to say what is and isn’t a valid device. Effectively outsourcing this to an “attestation ca”. Which means we need something to identify our devices.

Luckily we are using step-ca and they support device-attest-01, right?

Oh, enterprise only.

But I did write an attestation server 2 years ago to issue short-lived ssh certificates bound to the TPM.

“Writing an attestation ca for step-ca should be easy?” is what told myself 3 months ago.

attezt

attezt is my attempt to provide the supported tooling for ACME servers that implements device-attest-01.

https://github.com/Foxboron/attezt

When people hear “attestation” and “device identity” they fear that this is a heinous scheme to control our devices. But let me stress that things like device-attest-01 is practically useless outside of an enterprise public key infrastructure. Alternatively you, like me, have 2-3 devices and you would like to hand out access to things you host through short-lived certificates instead of long-lived certificates you never update.

The project implements a couple of things, but lets focus on the client attezt and the attestation ca atteztd first.


Note: This project is an entire work in progress. I have not packaged this properly yet, nor provided installation methods. They are expecting to run as root.

Your mileage might vary.


First we will create and setup a CA for our attestation server, and then run the server.

attezt:~# attezt ca create
2026/03/21 16:51:16 Creating certificate authority certificates...
attezt:~# atteztd
2026/03/21 16:57:12 HTTP server listening on :8080
2026/03/21 16:57:12 Varlink socket on /run/attezt/dev.attezt.Server

For the sake of simplicity in this blog post we imagine the server is running on attezt.home.arpa. It helps make rest of the post make more sense.

For the attestation server to identify our device we need to enroll the hash of our Endorsement Key (EK) into the inventory of our attestation ca. Each TPM has a signed endorsement certificate that pairs with the EK which chains up to the Certificate Authority of the TPM producer.

λ ~ » tpm2_nvread 0x01c00002 > ekpub
λ ~ » openssl x509 -in ekpub -pubkey -noout | openssl pkey -pubin -outform der | sha256sum
2ea8888a4a935bfd418e6a700785655b0d2711abc52e71f1dbeeee03e9650396  -

Here we are reading off the RSA endorsement certificate from our TPM, getting the public key and hashing it. This uniquely identifies a device and we can use this in our attestation ca to verify we are talking with a known and expected machine.

attezt ca implements the server configuration parts of attezt.

attezt:~# attezt ca enroll 2ea8888a4a935bfd418e6a700785655b0d2711abc52e71f1dbeeee03e9650396
attezt:~# attezt ca list
2ea8888a4a935bfd418e6a700785655b0d2711abc52e71f1dbeeee03e9650396

This ensures that attezt is only going to sign certificates from machines that matches one of the EK hashes.

We need to tell our smallstep ACME CA about our new method to provision certificates. We are going to fetch the certificate chain from attezt and make a new provision that uses device-attest-01.

I’m assuming here that you are already running a smallstep certificate authority on your infrastructure, In my case it’s running on ca.home.arpa.

λ ~ » curl -O https://attezt.home.arpa/root.pem
λ ~ » step ca provisioner add acme-da \
   --type ACME \
   --challenge device-attest-01 \
   --attestation-format tpm \
   --attestation-roots ./root.pem

After this is done we should be able to issue new certificates.

The way this is done is that we will make a TPM bound key on our system, and then create an Attestation Key. We have an endorsement key on our system, but to preserve privacy this key can only decrypt things and not sign anything. This ensures that we don’t build systems that would demand us to sign things with a uniquely identifiable key.

Instead we have to take a round trip where the attestation server encrypts a challenge to our Endorsement Key, which we decrypt and then ship to the attestation server. The attestation server will then sign our attestation key, after validating it was created on our system, which is what we will use to sign our device certificate.

The signed device certificate and the attestation key signed by our attestation ca is what gets sent to our ACME server for validation before issuing a certificate.

The attestation format our ACME server accepts is described by the Webauthn standard.

λ ~ » step ca certificate \
  --attestation-uri 'tpmkms:name=device' \
  --attestation-ca-url 'https://attezt.home.arpa' \
  --provisioner acme-da framework device.crt device.tss
✔ Provisioner: acme-da (ACME)
TPM INFO:
Version: TPM 2.0
Interface: kernel-managed
Manufacturer: Nuvoton Technology (<NTC>, 4E544300, 1314145024)
Vendor info: NPCT75x"!!4rls
Firmware version: 7.2
Using Device Attestation challenge to validate "framework" . done!
Waiting for Order to be 'ready' for finalization .. done!
Finalizing Order .. done!
✔ Certificate: device.crt
✔ Private Key: tpmkms:name=device

Cool!

Now we have a new device.crt file which is a device certificate signed by our ACME server.

“Okay, but what can we use it for?”, you might asking.

And at this point originally I was going to write up a blog post pointing out how I had written a cool new attestation ca server that slots into the smallstep ecosystem. I wanted a demo to prove how it works, but I was stumped.

It turns out the key part of this file is stored as a json blob under ~/.step/tpm and there was no clear way for me to use it. I tried having the key outputted into several different formats but the step front end would not let me get anything I could use.

I figured out that smallstep has written an agent to enable PKCS11 sockets for things like browsers or generic signing functionality for enrolled devices.

But it’s proprietary.

attezt-agent

Turns out I had to write more things.

Most applications do not understand TPM keys. We need a common format for our private key and while there are some attempts at standardization it is a bit hit and miss what is supported where and how. One solution which is somewhat popular here is to use the p11-kit suite of tools.

PKCS11 is effectively a standard C interface for cryptography devices. It’s terrible and hard to use and something out of a horror book. However a lot of things speak PKCS11 so it’s convenient to use this interface. The p11-kit project implements an RPC client/server architecture that allows us to abstract away the C ABI into convenient API calls over a socket. It makes it easier for us to implement a server that can deal with our TPM keys.

attezt-agent is intended as an application that runs as root on a user system to manage device bound certificates, and serve these over our p11-kit RPC tunnel. It implements an ACME client that communicated with our smallstep ACME server.

λ ~ » sudo attezt-agent
2026/03/15 16:33:55 p11kit-server is running
2026/03/15 16:33:55 export P11_KIT_SERVER_ADDRESS=unix:path=/run/attezt/p11kit.socket
2026/03/15 16:33:55 varlink service is running
2026/03/15 16:33:55 Running at: /run/attezt/dev.attezt.Agent

It effectively runs in the background and provides a varlink service that our attezt client can talk through, and a p11-kit socket for our PKCS11 needs.

At this point we just need to replace our calls with step with our new attezt client and we can issue our own device certificates from our ACME server.

λ ~ » attezt status
Status:
    Endorsement Key: 2ea8888a4a935bfd418e6a700785655b0d2711abc52e71f1dbeeee03e9650396
  Enrollment status: false
  
λ ~ » attezt enroll --acme "https://ca.home.arpa/acme/acme-da" --attestation "https://attezt.home.arpa"
λ ~ » attezt status
Status:
    Endorsement Key: 2ea8888a4a935bfd418e6a700785655b0d2711abc52e71f1dbeeee03e9650396
  Enrollment status: true
        ACME Server: https://ca.home.arpa/acme/acme-da
 Attestation Server: https://attezt.home.arpa

Certificate chain:
X.509v3 TLS Certificate (RSA 2048) [Serial: 9209...5502]
  Subject:     framework
  Issuer:      Linderud Internal CA Intermediate CA
  Provisioner: acme-da
  Valid from:  2026-03-21T21:24:05Z
          to:  2026-03-22T21:24:05Z

X.509v3 Intermediate CA Certificate (ECDSA P-256) [Serial: 3050...3071]
  Subject:     Linderud Internal CA Intermediate CA
  Issuer:      Linderud Internal CA Root CA
  Valid from:  2026-01-03T15:15:45Z
          to:  2036-01-01T15:15:45Z

Tada! It works!

Currently we only issue short-lived 24 hour certificates.

Now we can query our TPM keys with the pkcs11-tool.

λ ~ » export P11_KIT_SERVER_ADDRESS=unix:path=/run/attezt/p11kit.socket
λ ~ » pkcs11-tool --module /usr/lib/pkcs11/p11-kit-client.so --list-slots
Available slots:
Slot 0 (0x1): Attezt Attestation Agent
  token label        : Attestation Trust
  token manufacturer : attezt
  token model        : attezt-agent
  token flags        : token initialized, readonly
  hardware version   : 0.1
  firmware version   : 0.1
  serial num         : 12345678
  pin min/max        : 0/0
  uri                : pkcs11:model=attezt-agent;manufacturer=attezt;serial=12345678;token=Attestation%20Trust

λ ~ » pkcs11-tool --module /usr/lib/pkcs11/p11-kit-client.so --list-objects
Using slot 0 with a present token (0x1)
Certificate Object; type = X.509 cert
  label:      Attezt TPM Certificate
  subject:    DN: CN=framework
  serial:     D47FCD127ACCB2133B8163B2AA27CBE6
  ID:         ef1e59d6a7a29acc13f08fd4921191a6fae563ce
  uri:        pkcs11:model=attezt-agent;manufacturer=attezt;serial=12345678;token=Attestation%20Trust;id=%ef1e59d6a7a29acc13f08fd4921191a6fae563ce;object=Attezt%20TPM%20Certificate;type=cert
Private Key Object; RSA
  label:      Attezt TPM key
  ID:         ef1e59d6a7a29acc13f08fd4921191a6fae563ce
  Usage:      decrypt, sign
  Access:     sensitive, always sensitive, never extractable
  uri:        pkcs11:model=attezt-agent;manufacturer=attezt;serial=12345678;token=Attestation%20Trust;id=%ef1e59d6a7a29acc13f08fd4921191a6fae563ce;object=Attezt%20TPM%20key;type=private
Certificate Object; type = X.509 cert
  label:      Intermediate Certificate
  subject:    DN: O=Linderud Internal CA, CN=Linderud Internal CA Intermediate CA
  serial:     E57E41F6144D9C599B87D39322A7C57F
  ID:         20b13640446a2c2329b76dca2d83d7c7c6ed1b63
  uri:        pkcs11:model=attezt-agent;manufacturer=attezt;serial=12345678;token=Attestation%20Trust;id=%20b13640446a2c2329b76dca2d83d7c7c6ed1b63;object=Intermediate%20Certificate;type=cert
Public Key Object; EC  EC_POINT 256 bits
  EC_POINT:   044104b83d658c14a7e623f2c07f1755e890d1de85abf6042cc395df98e82169e26154a406e481e9291a535622da2b73a1ad9ccdabeb2e48968b7dd45e7ca4feb0e2c4
  EC_PARAMS:  06082a8648ce3d030107 (OID 1.2.840.10045.3.1.7)
  label:      Intermediate Certificate Public Key
  ID:         20b13640446a2c2329b76dca2d83d7c7c6ed1b63
  Usage:      verify
  Access:     none
  uri:        pkcs11:model=attezt-agent;manufacturer=attezt;serial=12345678;token=Attestation%20Trust;id=%20b13640446a2c2329b76dca2d83d7c7c6ed1b63;object=Intermediate%20Certificate%20Public%20Key;type=public

And we have TPM keys! In the future I plan on making them renew automatically and so on. But this will make our demos much easier to deal with.

Putting it all together

So finally, now we can use our device bound certificates to do some authentication. For the sake of this demonstration I will be using our certificate for mTLS authentication.

I will set up a quick nginx server on mtls.home.arpa that only allows authenticated devices to communicate with it.

# curl https://attezt.home.arpa/root.pem > /etc/nginx/ca.crt
# cat /etc/nginx/nginx.d/30-mtls.home.arpa.conf

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mtls.home.arpa;

    access_log /var/log/nginx/mtls.access.log;
    error_log /var/log/nginx/mtls.error.log;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache off;
    ssl_client_certificate /etc/nginx/ca.crt;
    ssl_verify_client      on;

    include snippets/acme-internal.conf;

    location / {
        if ($ssl_client_verify != SUCCESS) { return 403; }
        add_header Content-Type 'text/html; charset=utf-8';
        return 200 '<html><body>Hello Worrld</body></html>';
    }
}

This configuration should allow nginx to only forward a 200 OK if we have authenticated. We will use curl to test!

λ » curl https://mtls.home.arpa
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx/1.28.1</center>
</body>
</html>

Ohno, we can’t authenticate.

Lets help curl by telling it to use the p11-kit-client.so PKCS11 module, which should communicate with our p11-kit RPC server.

Note that I had to install libp11 for pkcs11 to work with curl on Arch Linux.

λ ~ » export PKCS11_MODULE_PATH=/usr/lib/pkcs11/p11-kit-client.so 
λ ~ » export P11_KIT_SERVER_ADDRESS=unix:path=/run/attezt/p11kit.socket
λ ~ » curl --key 'pkcs11:object=Attezt%20TPM%20key;type=private' --cert /var/lib/attezt/device.crt https://mtls.home.arpa
<html><body>Hello World</body></html>% 

And we have authenticated!

You can also use this with a browser like qutebrowser or chromium, but it requires a bit more setup.

First you need to tell nssdb about the PKCS11 library,and we need to ensure P11_KIT_SERVER_ADDRESS is exported, as we have done previously.

λ ~ » modutil -dbdir ~/.pki/nssdb -add attezt -libfile /usr/lib/pkcs11/p11-kit-client.so
λ ~ » export P11_KIT_SERVER_ADDRESS=unix:path=/run/attezt/p11kit.socket

Now, we also need to tell chromium that we want to use a certificate for some urls. This can be done by specifying a policy.

λ ~ » cat /etc/chromium/policies/managed/mtls.json
{
  "AutoSelectCertificateForUrls": [
    "{\"pattern\":\"https://mtls.home.arpa\",\"filter\":{\"ISSUER\":{\"O\":\"Linderud Internal CA\"}}}"
  ]
}

At which point chromium should use our PKCS11 agent to authenticate with the TLS server by looking for certificates issues by my internal CA “Linderud Internal CA”.

In summary

attezt is a work in progress and I don’t expect anyone to deploy this into production. But if people find FOSS tooling around this interest then come join hack on it.

The attestation protocol is currently only something smallstep implements, it would be nice with something generic here. The inventory system is rudimentary, but it works for EK hash enrollment. Patches welcome and I promise no warranty.

But, what about those ssh certificates?

Yes, well. About that.

It turns out that step-ca doesn’t provide a way for me to sign my ssh certificates through ACME and my attestation server. However I have some motivation to see how much of this I can hack together at this point.

ACME allows us to specify the certificate bundle type through Content-Type. This could probably be used to issue an ssh certificate instead. If we want SSH certificates we also need a way to validate, or attest, to different principals, where I wonder if the IMA subsystem in Linux or systemd can maybe record the hostname and available users into the TPM eventlog for attestation. This could also be delegated to the inventory system.

At this point we just need a way to tell ACME which properties we want into the issues certificate.

I suspect this could be hacked together for personal use, but I’m not sure how something people would like to use for serious use would look like.


Thanks to everyone who has been helpful answering questions I’ve had on these topics! Would never have survived half of the topics involved otherwise.


Back to posts


github mastodon twitter email
A previous track button A shuffle button! A next track button A glider!