bschaatsbergen/go-tpm-tls-bench

Research: what it costs to authenticate a TLS client with a TPM-held private key

0

stars

5

commits

Go

primary language

Sep 4, 2026

updated

benchmark
go
mtls
tpm2

README

go-tpm-tls-bench

Measures what it costs to authenticate a TLS client with a private key that's held in a TPM rather than in process memory.

In TLS, a client proves ownership of its certificate by signing the handshake transcript (CertificateVerify in TLS 1.3). When the private key lives in a TPM, that signature is produced inside the TPM, so the key never enters process memory and can't be copied off the machine. The trade-off is latency: a TPM signs in milliseconds where a software key signs in microseconds. This benchmark measures that per-connection cost, and how much of it you get back with TLS session resumption and connection reuse.

The TPM key is accessed through go-tpm-tls, so what gets timed is the same code path an application would use. Since that package attaches to existing keys rather than creating them, the benchmark first provisions a key the way an attestation agent would, and evicts it when it's done.

Every scenario runs twice: once with the TPM key and once with an in-memory software key of the same type and curve. The signer is wrapped in a counter, so the number of signatures per scenario is counted rather than inferred from latency.

Running the benchmark

You'll need Linux, a TPM, and root access to /dev/tpmrm0:

go build -o go-tpm-tls-bench .
sudo ./go-tpm-tls-bench

The benchmark writes a key to a persistent handle (0x81000004 by default) and refuses to start if something already lives there. There are a few flags:

  • -handle: use a different persistent handle.
  • -curve p384: measure P-384 instead of the default P-256.
  • -evict: free a handle left behind by a previous run that was killed before it could clean up.

The tests don't need a TPM or root, and run on any platform:

go test ./...
golangci-lint run

Results

I ran the benchmark on two Google Cloud Confidential VMs, both on Ubuntu 24.04: an n2d-standard-2 with AMD SEV-SNP and a c3-standard-4 with Intel TDX. On both, the vTPM is implemented in hypervisor software. Everything runs sequentially over a single stream.

This is what the tool prints, here on the SEV-SNP machine with the default P-256 key:

TPM key at 0x81000004 on /dev/tpmrm0, curve p256, non-exportable per the TPM: true

Raw signing, 100 signatures
  TPM key            median    2.16 ms   p95    2.50 ms       456 signature/s
  software key       median    0.04 ms   p95    0.07 ms     21530 signature/s
  TPM key, transient median   21.70 ms   p95   22.19 ms        46 signature/s

Persistent key with an unrelated transient object loaded, 100 signatures
  nothing loaded     median    2.11 ms   p95    2.48 ms       472 signature/s
  same transport     median   21.37 ms   p95   21.88 ms        48 signature/s
  other transport    median    2.20 ms   p95    2.52 ms       453 signature/s

One connection per request, 50 connections, no resumption
  TPM key            median    3.65 ms   p95    4.91 ms       258 conn/s    50 signatures
  software key       median    1.52 ms   p95    1.83 ms       635 conn/s    50 signatures

One connection per request, 50 connections, session resumption
  TPM key            median    1.02 ms   p95    1.17 ms       906 conn/s     1 signatures   49 resumed
  software key       median    1.01 ms   p95    1.56 ms       945 conn/s     1 signatures   49 resumed

One connection reused for 50 requests
  TPM key            median    0.03 ms   p95    0.04 ms     31936 req/s     1 signatures
  software key       median    0.03 ms   p95    0.04 ms     31725 req/s     1 signatures

Medians across both machines and both curves:

SNP P-256SNP P-384TDX P-256TDX P-384
signature, persistent key2.16 ms2.51 ms1.99 ms3.29 ms
signature, transient key21.70 ms22.01 ms30.10 ms31.74 ms
signature, software key0.04 ms0.25 ms0.04 ms0.22 ms
full handshake3.65 ms4.74 ms3.25 ms5.20 ms
resumed handshake1.02 ms1.03 ms0.72 ms0.73 ms
cold handshakes per second258204299189

Resumption and reuse don't apply to raw signing, since there's no connection involved.

Observations

A TPM signature takes single-digit milliseconds, compared to tens of microseconds in software, and it accounts for most of a full handshake: 2.16 ms out of 3.65 ms on SEV-SNP, and 1.99 ms out of 3.25 ms on TDX. Since the TPM executes one command at a time, a machine tops out at a few hundred new mutually authenticated connections per second. Note that this limit is per machine, as every machine has its own TPM.

The signature counts are identical everywhere: 50 signatures for 50 fresh connections, 1 for 50 connections with resumption, and 1 for 50 requests over a reused connection. A handshake signature can't be reused across connections, because the transcript includes fresh randomness from both peers, so each signature is only valid for a single connection. What resumption and reuse avoid is doing the handshake at all, and once the signature is out of the path, the TPM key and the software key perform within noise of each other on both machines. In other words, the cost scales with the number of handshakes, not the number of requests.

Three more things stood out, the first two of which vary by platform in size rather than direction.

Holding the key as a transient object costs about ten times as much as a persistent key on SEV-SNP, and fifteen times as much on TDX. The kernel resource manager context-saves transient objects between commands, so every signature pays for swapping the key back into the TPM, while a persistent object stays in the TPM's own storage. If you can, provision a persistent key.

That cost is charged to the transport rather than to the object. A persistent key nothing else is touching signs in 2.11 ms, and in 21.37 ms while an unrelated transient object sits loaded on the same descriptor: the same factor of ten, on a key that was never transient. It stops at the descriptor, where the same object costs 2.20 ms, so this is a property of the connection you sign on rather than of the machine. Provisioning a persistent key is necessary but not sufficient, then. The transport it signs on has to stay clear of transient objects too, and that is the part of this a workload actually controls. Measured on SEV-SNP with P-256.

A P-384 signature costs 16% more than P-256 on SEV-SNP and 65% more on TDX, which comes down to 204 and 189 cold handshakes per second respectively. Session resumption erases the difference on both platforms. This is worth knowing if a policy such as CNSA 1.0 puts you on P-384, since the extra cost only lands on cold handshakes.

Caveats

Bear in mind that these numbers come from two instance types in a single cloud. The GCE vTPM is implemented in hypervisor software on both, so they say nothing about discrete TPM hardware or about a vTPM running inside the confidential VM. The two machines also differ in CPU generation as well as TEE, so treat the columns as two data points rather than a comparison of AMD against Intel. Everything runs single-threaded, since the TPM serializes commands regardless, and resumption is measured against a server that issues tickets in the same process.

Layout

  • main.go: the scenarios and output.
  • provision.go: provisions the key into the TPM and evicts it afterwards.
  • pki.go: a throwaway CA for the test certificates.
  • bench.go: timing, signature counting, and the TLS test server.
  • bench_test.go: asserts that the harness measures a real mTLS exchange.

Contributors

bschaatsbergen/go-tpm-tls-bench

Research: what it costs to authenticate a TLS client with a TPM-held private key

0

stars

5

commits

Go

primary language

Sep 4, 2026

updated

benchmark
go
mtls
tpm2

README

go-tpm-tls-bench

Measures what it costs to authenticate a TLS client with a private key that's held in a TPM rather than in process memory.

In TLS, a client proves ownership of its certificate by signing the handshake transcript (CertificateVerify in TLS 1.3). When the private key lives in a TPM, that signature is produced inside the TPM, so the key never enters process memory and can't be copied off the machine. The trade-off is latency: a TPM signs in milliseconds where a software key signs in microseconds. This benchmark measures that per-connection cost, and how much of it you get back with TLS session resumption and connection reuse.

The TPM key is accessed through go-tpm-tls, so what gets timed is the same code path an application would use. Since that package attaches to existing keys rather than creating them, the benchmark first provisions a key the way an attestation agent would, and evicts it when it's done.

Every scenario runs twice: once with the TPM key and once with an in-memory software key of the same type and curve. The signer is wrapped in a counter, so the number of signatures per scenario is counted rather than inferred from latency.

Running the benchmark

You'll need Linux, a TPM, and root access to /dev/tpmrm0:

go build -o go-tpm-tls-bench .
sudo ./go-tpm-tls-bench

The benchmark writes a key to a persistent handle (0x81000004 by default) and refuses to start if something already lives there. There are a few flags:

  • -handle: use a different persistent handle.
  • -curve p384: measure P-384 instead of the default P-256.
  • -evict: free a handle left behind by a previous run that was killed before it could clean up.

The tests don't need a TPM or root, and run on any platform:

go test ./...
golangci-lint run

Results

I ran the benchmark on two Google Cloud Confidential VMs, both on Ubuntu 24.04: an n2d-standard-2 with AMD SEV-SNP and a c3-standard-4 with Intel TDX. On both, the vTPM is implemented in hypervisor software. Everything runs sequentially over a single stream.

This is what the tool prints, here on the SEV-SNP machine with the default P-256 key:

TPM key at 0x81000004 on /dev/tpmrm0, curve p256, non-exportable per the TPM: true

Raw signing, 100 signatures
  TPM key            median    2.16 ms   p95    2.50 ms       456 signature/s
  software key       median    0.04 ms   p95    0.07 ms     21530 signature/s
  TPM key, transient median   21.70 ms   p95   22.19 ms        46 signature/s

Persistent key with an unrelated transient object loaded, 100 signatures
  nothing loaded     median    2.11 ms   p95    2.48 ms       472 signature/s
  same transport     median   21.37 ms   p95   21.88 ms        48 signature/s
  other transport    median    2.20 ms   p95    2.52 ms       453 signature/s

One connection per request, 50 connections, no resumption
  TPM key            median    3.65 ms   p95    4.91 ms       258 conn/s    50 signatures
  software key       median    1.52 ms   p95    1.83 ms       635 conn/s    50 signatures

One connection per request, 50 connections, session resumption
  TPM key            median    1.02 ms   p95    1.17 ms       906 conn/s     1 signatures   49 resumed
  software key       median    1.01 ms   p95    1.56 ms       945 conn/s     1 signatures   49 resumed

One connection reused for 50 requests
  TPM key            median    0.03 ms   p95    0.04 ms     31936 req/s     1 signatures
  software key       median    0.03 ms   p95    0.04 ms     31725 req/s     1 signatures

Medians across both machines and both curves:

SNP P-256SNP P-384TDX P-256TDX P-384
signature, persistent key2.16 ms2.51 ms1.99 ms3.29 ms
signature, transient key21.70 ms22.01 ms30.10 ms31.74 ms
signature, software key0.04 ms0.25 ms0.04 ms0.22 ms
full handshake3.65 ms4.74 ms3.25 ms5.20 ms
resumed handshake1.02 ms1.03 ms0.72 ms0.73 ms
cold handshakes per second258204299189

Resumption and reuse don't apply to raw signing, since there's no connection involved.

Observations

A TPM signature takes single-digit milliseconds, compared to tens of microseconds in software, and it accounts for most of a full handshake: 2.16 ms out of 3.65 ms on SEV-SNP, and 1.99 ms out of 3.25 ms on TDX. Since the TPM executes one command at a time, a machine tops out at a few hundred new mutually authenticated connections per second. Note that this limit is per machine, as every machine has its own TPM.

The signature counts are identical everywhere: 50 signatures for 50 fresh connections, 1 for 50 connections with resumption, and 1 for 50 requests over a reused connection. A handshake signature can't be reused across connections, because the transcript includes fresh randomness from both peers, so each signature is only valid for a single connection. What resumption and reuse avoid is doing the handshake at all, and once the signature is out of the path, the TPM key and the software key perform within noise of each other on both machines. In other words, the cost scales with the number of handshakes, not the number of requests.

Three more things stood out, the first two of which vary by platform in size rather than direction.

Holding the key as a transient object costs about ten times as much as a persistent key on SEV-SNP, and fifteen times as much on TDX. The kernel resource manager context-saves transient objects between commands, so every signature pays for swapping the key back into the TPM, while a persistent object stays in the TPM's own storage. If you can, provision a persistent key.

That cost is charged to the transport rather than to the object. A persistent key nothing else is touching signs in 2.11 ms, and in 21.37 ms while an unrelated transient object sits loaded on the same descriptor: the same factor of ten, on a key that was never transient. It stops at the descriptor, where the same object costs 2.20 ms, so this is a property of the connection you sign on rather than of the machine. Provisioning a persistent key is necessary but not sufficient, then. The transport it signs on has to stay clear of transient objects too, and that is the part of this a workload actually controls. Measured on SEV-SNP with P-256.

A P-384 signature costs 16% more than P-256 on SEV-SNP and 65% more on TDX, which comes down to 204 and 189 cold handshakes per second respectively. Session resumption erases the difference on both platforms. This is worth knowing if a policy such as CNSA 1.0 puts you on P-384, since the extra cost only lands on cold handshakes.

Caveats

Bear in mind that these numbers come from two instance types in a single cloud. The GCE vTPM is implemented in hypervisor software on both, so they say nothing about discrete TPM hardware or about a vTPM running inside the confidential VM. The two machines also differ in CPU generation as well as TEE, so treat the columns as two data points rather than a comparison of AMD against Intel. Everything runs single-threaded, since the TPM serializes commands regardless, and resumption is measured against a server that issues tickets in the same process.

Layout

  • main.go: the scenarios and output.
  • provision.go: provisions the key into the TPM and evicts it afterwards.
  • pki.go: a throwaway CA for the test certificates.
  • bench.go: timing, signature counting, and the TLS test server.
  • bench_test.go: asserts that the harness measures a real mTLS exchange.

See what people are saying

Contributors

Languages

Go

100.0%