Signing TLS handshakes inside a TPM

I’ve been doing remote attestation work on confidential VMs. This post is one piece of that rather than the whole of it: wherever the attestation itself lands, the machine still needs an identity it can hold and use afterwards, and in my case using it means authenticating to other services with mutual TLS.

An application that does mutual TLS authenticates with a client certificate, and on disk that is almost always two files. client.crt is the one you show: it carries a public key and a CA’s signature over that key, and it isn’t a secret. client.key is the one you sign the handshake with, and the server checks that signature against the public key in the certificate you just showed it.

So the identity is entirely in the second file, and your process reads it into memory at startup. From that moment the key is in a heap dump, a core file, a swapped page, a hypervisor snapshot of the VM’s memory, and in reach of anything that gets code execution in the process. Copy it and you are that machine everywhere that machine is trusted: the secret store, the internal API that only accepts client certificates, the database that maps a certificate subject to a role.

Which is the part I keep coming back to. A key in a file is not really a machine identity. It’s a bearer credential that happens to be stored on a machine, and whoever reads it becomes that machine, anywhere, until somebody notices and revokes the certificate.

Let me be specific about the threat, because I think this gets oversold. What I’m defending against is an attacker who gets read access inside the guest: a file read, a core dump, an SSRF that reaches the filesystem, a backup that went somewhere it shouldn’t. I want them to leave without a working copy of the machine’s identity.

What I’m not defending against is someone with persistent code execution using that identity while they’re still on the box. That’s a different problem and a TPM doesn’t solve it.

So the thing I need has four properties. It signs TLS handshakes, because that is how the identity actually gets used. It never exists outside the machine, so that reading memory or a disk gets an attacker nothing they can carry away. It needs no second credential to reach it, because whatever that credential was would immediately become the new thing worth stealing. And it works with an ordinary TLS stack, because I don’t want a bespoke protocol between two services that already speak TLS perfectly well.

A TPM does all four. It will sign with a key and it will not hand the key over, so the process never holds a secret at all. On the machine I’ve been using that’s a virtual TPM, and I wrote about the chip itself in an earlier post. Being a confidential VM helps with exactly one line of that exposure list, since guest memory is encrypted against the host and a snapshot taken from underneath gets ciphertext. Everything else on the list happens inside the guest, where it does nothing at all.

why not something else

Three alternatives come up before a TPM does, and all three are reasonable. Run each against those four properties and you can see what each one misses.

File permissions are worth tightening and fail the second property. SELinux, RLIMIT_CORE=0 and mlockall genuinely close vectors, but a process that uses the key has the key in its address space, and ptrace, /proc/pid/mem and code execution in that process are all untouched by the mode bits on a file that has already been read.

Short-lived certificates fail the second property too, and partly the third. They shrink the window a stolen key is useful in, which is valuable and probably more valuable than this post if you can only do one. They don’t stop the copy, and you still need some credential to authenticate the renewal.

A KMS is the real competitor. Cloud KMS or Vault’s transit engine holds a key that genuinely never leaves, so it passes the second property outright, and for a lot of teams it is the right answer. What it fails is the third. A KMS has to authenticate you somehow, so there is now a credential sitting on the box that unlocks the signing key, and that credential is the thing an attacker copies instead. You have moved the problem rather than removed it, unless that credential is itself bound to hardware.

There’s a practical cost on top of that, which is that every cold handshake now depends on a network round trip to a service that can be down. I would rather not put a remote dependency in the path that decides whether two of my services can talk to each other.

That’s the third property, and it’s the one that decides this. Something has to be the root: held by the machine, unstealable, and reachable without presenting anything first. A TPM is that thing, and everything else here (a KMS token, a short-lived workload certificate, a PKCS#11 token you’d have to go and buy) can hang off it rather than compete with it.

None of which makes a TPM more secure than a well-run KMS. It’s local, it needs no credential to reach, and on most machines built in the last decade it is already there.

what TLS asks of a key

Sign, and nothing else, is all a TPM will do with a key. That happens to be all TLS needs.

In TLS 1.3 a peer proves it holds the private key for the certificate it just sent by signing a message defined in CertificateVerify. That message is 64 bytes of 0x20, the string TLS 1.3, client CertificateVerify, a zero byte, and then the transcript hash, which is a running hash of every handshake message the two sides have exchanged so far. All of it gets hashed, and that digest is what the key signs. A server authenticating with a certificate signs the same structure with server in place of client, which is what stops either side’s signature being replayed back at the other.

Two things follow from the transcript being in there. Because it includes fresh randomness from both peers, the signature is good for this one connection and no other, so it can’t be replayed. And because there’s one transcript per handshake, the key is asked for exactly one signature over one digest.

That’s the whole job. The private key never decrypts anything and never derives the session keys. Those come out of an ephemeral Diffie-Hellman exchange it takes no part in. Once CertificateVerify is on the wire, the key is done for the life of the connection.

Go asks for exactly that much and no more. A tls.Certificate holds a PrivateKey, and what that field actually has to satisfy is crypto.Signer:

type Signer interface {
	Public() crypto.PublicKey
	Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error)
}

crypto/tls builds the CertificateVerify message itself, hashes it itself, and calls Sign with the digest. It never asks for key material. This is the same door PKCS#11 modules and cloud KMS keys have always come through, which is why I said above that they all look alike from Go’s side. It was never a TPM feature. The interface is just narrow enough that a TPM fits through it.

Now compare that to TPM2_Sign: digest in, signature out, key stays put. Same shape. So the library I ended up writing is a Sign that forwards to the TPM, plus some care around attaching to the right key.

process memoryTPMcrypto/tlshashes the transcriptkeyTPM2_Signdigestsignature
Only the digest and the signature cross. The key never does, so a heap dump, a core file, or a swapped page has nothing to leak.

the client

Here’s the whole client. The thing to notice is that nothing in it names the key:

leaf, err := x509.ParseCertificate(certDER)
if err != nil {
	return err
}

// The certificate picks the key.
key, err := tpmtls.OpenForCertificate(tpmtls.DefaultDevice, leaf)
if err != nil {
	return err
}
defer key.Close()

conn, err := tls.Dial("tcp", "store.example.com:443", &tls.Config{
	MinVersion:   tls.VersionTLS13,
	Certificates: []tls.Certificate{key.TLSCertificate(leaf.Raw)},
})

There’s no handle, no key path, and no device configuration beyond a default. The tls.Config doesn’t mention a TPM either. It’s the same config you’d write for a key in a file, and it works just as well in an http.Transport, in tls.Listen, or on an http.Server.

OpenForCertificate comes from go-tpm-tls, a thin layer I wrote over go-tpm and go-tpm-tools. That it takes the certificate and nothing else is the one design decision I went back and forth on, so it’s worth explaining.

A key in a TPM sits at a handle, which is a number like 0x81000004 saying where the object lives. The obvious thing is to put that number in configuration, and I wrote it that way first. It’s wrong. The handle is chosen by whoever provisioned the key, so a value that works on one machine fails on the next, and it fails at the first handshake rather than at startup, which is the worst possible time to find out. But your application already holds the certificate it’s about to present, and the public key inside that certificate says exactly which key in the TPM to sign with. So the lookup reads the persistent handles, compares public keys, and picks the match. If nothing matches you get tpmtls.ErrNotFound at startup.

It also fits how the two halves age. The key stays in the TPM for the life of the machine and the certificate over it is what rotates, so the certificate is what your application gets handed fresh and the key is what it has to go and find. If you do know the handle, tpmtls.Open takes one.

Two smaller things in that snippet. tpmtls.DefaultDevice is /dev/tpmrm0, the kernel resource manager, and you want that rather than the raw /dev/tpm0 because it gives every open file descriptor its own context, so processes sharing the TPM don’t evict each other’s objects. Hold onto that per-descriptor detail, it comes back in the benchmarks in a way I didn’t expect. Opening it needs root or membership of the tss group.

And you should pin MinVersion: tls.VersionTLS13 rather than inheriting Go’s default of 1.2, though I want to be precise about why, because the obvious reason is mostly obsolete. In TLS 1.2 a server certificate’s key can be asked to decrypt a premaster secret under the static RSA key exchange suites, and a TPM signing key cannot do that. Modern Go already puts those suites in its disabled list, and client authentication in TLS 1.2 is a signature either way, so this is not a live risk so much as one fewer thing to reason about. Pin it because TLS 1.3 makes signing the only thing that can ever be asked of the key, and because it is what the numbers later in this post were measured on.

It works, but it rests on six things being true, and only one of them is yours.

what the code assumes

Five of the six were decided before your process started, which is the point I made at the top: the security property is set at provisioning time and your code is attaching to the result.

A signing key already exists in the TPM. Something else put it there, typically an attestation agent that generated the key inside the TPM, bound the public half into hardware evidence, and had it certified. go-tpm-tls has no way to create a key, deliberately. It also won’t evict one. Close lets go of the key and leaves the handle alone, because detaching from a key you didn’t provision shouldn’t destroy it for everyone else on the machine.

The key is unrestricted. A restricted key, which is what an attestation key is, will only sign data the TPM itself hashed or produced. TPM2_Sign wants a validation ticket proving that, and a transcript hash handed in by crypto/tls has no such ticket, so the TPM answers TPM_RC_TICKET. If your agent provisioned an attestation key and a TLS key, you want the second one.

The key is ECDSA, on P-256 or P-384. RSA keys load and sign, but not the way crypto/tls asks, which I’ll come back to.

The key has no auth value. go-tpm-tls loads with a nil session, which resolves to a null session, so a key guarded by a password or a policy will not load. That is a real limitation and not a security claim: an auth value would be one more thing an attacker on the box has to obtain, and if your provisioning sets one, this package is not what you want yet.

The key sits at a persistent handle. Persistent handles run from 0x81000000 to 0x81FFFFFF, survive a reboot, and can be reached by whatever process comes along later. A transient handle works too, but only over the TPM connection that created it, and it costs about ten times as much per signature. If you have any say in provisioning, ask for persistent.

Your process can open /dev/tpmrm0. This is the one that’s yours, and the only one you can fix without re-provisioning the machine. It needs root or the tss group.

Since you inherit the other five, I’d check what you actually got rather than trusting whatever runbook provisioned it. NonExportable reads the attributes back out of the TPM:

ok, err := key.NonExportable()
if err != nil {
	return err
}
if !ok {
	return fmt.Errorf("key at %#x can be duplicated out of the TPM", key.Handle())
}

That’s the fixedTPM attribute, which is the TPM telling you it will refuse to duplicate the private key to another TPM. Paired with sensitiveDataOrigin at creation time, which says the TPM generated the key rather than being handed one, you know the key has never existed anywhere else. NonExportable only reads the first of those two back; the second is a property of how it was provisioned.

From a shell it’s tpm2_getcap and tpm2_readpublic, worth running once so you know what the good case looks like. This is a GCE SEV-SNP guest with tpm2-tools 5.6:

$ sudo tpm2_getcap handles-persistent
- 0x81000004

$ sudo tpm2_readpublic -c 0x81000004
name: 000ba090006025e535419d16b12e7bdd219d20077aa4bd0fc99bc41cc5ed643005b3
name-alg:
  value: sha256
attributes:
  value: fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign
  raw: 0x40072
type:
  value: ecc
curve-id:
  value: NIST p256
scheme:
  value: ecdsa
scheme-halg:
  value: sha256

fixedtpm and sensitivedataorigin are the two that matter, sign without restricted is the third, and there is no private half anywhere in that output because there is no way to ask for one.

making a key to try this with

On a real machine an agent creates the key, but you need one to run any of the above, so here’s the short version.

Read the warning before you copy it. This makes a key that cannot leave the TPM, and that is all it does. Nothing binds it to what the machine booted, and no verifier anywhere has evidence of where it came from, so a certificate issued over it attests to nothing beyond “some TPM held this”. That is fine for running the code in this post and it is not provisioning. A real agent creates the key, binds the public half into a quote, and has it certified against that evidence, which is the part that makes anyone’s trust in it justified.

With that said, client here is go-tpm-tools/client:

// A throwaway key for trying out the code in this post.
template := tpm2.Public{
	Type:    tpm2.AlgECC,
	NameAlg: tpm2.AlgSHA256,
	Attributes: tpm2.FlagSign | tpm2.FlagSensitiveDataOrigin |
		tpm2.FlagUserWithAuth | tpm2.FlagFixedTPM | tpm2.FlagFixedParent,
	ECCParameters: &tpm2.ECCParams{
		Sign:    &tpm2.SigScheme{Alg: tpm2.AlgECDSA, Hash: tpm2.AlgSHA256},
		CurveID: tpm2.CurveNISTP256,
	},
}

const handle = tpmtls.Handle(0x81000004)

rwc, err := tpm2.OpenTPM(tpmtls.DefaultDevice)
if err != nil {
	return err
}
defer rwc.Close()

// Creates the key inside the TPM and persists it at the handle.
created, err := client.NewCachedKey(rwc, tpm2.HandleOwner, template, handle)
if err != nil {
	return err
}
created.Close() // let go of it; the key stays at the handle

That template is five of the six assumptions written out in code, which is a nice way to see them. FlagSensitiveDataOrigin means the TPM generates the private key rather than being handed one. FlagFixedTPM and FlagFixedParent mean it can’t be duplicated out. FlagSign with no FlagRestricted means it will sign a digest you give it, which is what CertificateVerify needs. The curve is P-256, NewCachedKey persists it rather than leaving it transient, and since no auth value is supplied the key ends up with an empty one, which is what lets go-tpm-tls load it with a null session.

You still need something to present, and key.CertificateRequest produces a CSR signed by the TPM that you can hand to whatever CA you’re testing against.

Evict the key when you’re done. Persistent slots are few and a key left behind survives reboots:

tpm2.EvictControl(rwc, "", tpm2.HandleOwner, handle, handle)

Owner auth is the empty string on the confidential VMs I ran this on. That is not true everywhere.

the other end doesn’t know

Here’s the verifying end. There’s no TPM on this side and nothing here knows about one:

pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)

srv := &http.Server{
	Addr: ":8443",
	TLSConfig: &tls.Config{
		MinVersion: tls.VersionTLS13,
		ClientAuth: tls.RequireAndVerifyClientCert,
		ClientCAs:  pool,
	},
}

The server checks a signature against the public key in a certificate that chains to a CA it trusts, and where the private half was sitting while that signature got made is invisible to it. I think that’s the most underrated part of this whole approach, because it means you can adopt it one service at a time, from either direction.

Nothing above is client-specific, incidentally. The server’s own key is a crypto.Signer too, so putting it in the TPM is the same swap and the same call:

key, err := tpmtls.OpenForCertificate(tpmtls.DefaultDevice, serverLeaf)
if err != nil {
	return err
}
defer key.Close()

srv.TLSConfig.Certificates = []tls.Certificate{key.TLSCertificate(serverLeaf.Raw)}

A server signs one CertificateVerify per full handshake exactly like a client does, so everything later in this post about cost applies unchanged, and the throughput ceiling matters a great deal more on a listener than on a dialler. Its clients need no changes at all, because from their side this is still an ordinary certificate.

It’s also the limit, and it’s the thing I see people get wrong when they describe this in a design review. A TPM changes how strong the possession claim is. It does not change what the far end verifies. If you want the verifier to know that the key is in a TPM, and which machine’s TPM, that has to be carried in the certificate, which means it has to come from attestation at issuance time. The handshake conveys none of it.

four ways it breaks

Four things broke while I was getting this working. Every one of them produces an error that mentions neither TLS nor the TPM, which is what made them annoying, so I’ve listed them by the message you’ll actually see.

salt length must be rsa.PSSSaltLengthAuto

You’re using an RSA key. TLS 1.3 requires RSA-PSS with a salt as long as the digest, so crypto/tls asks for exactly that. A TPM picks its own salt length and won’t be told otherwise, so go-tpm-tools refuses the request rather than produce a signature the peer would reject.

The key loads fine, so this fails at signing time in the middle of a handshake, in a message that mentions none of the above. Use P-256 or P-384 and the problem doesn’t exist.

key at 0x81000004 cannot sign: restricted keys are not supported

You attached to a restricted key, almost certainly the attestation key. It signs only what the TPM itself hashed, which it proves with a validation ticket, and a TLS transcript hash is caller-supplied and has no ticket.

You can watch this happen below go-tpm-tls. Hand a restricted key 32 random bytes and ask it to sign them, which is structurally what CertificateVerify does:

$ sudo tpm2_sign -c restricted.ctx -d -g sha256 -s ecdsa -o sig.bin digest.bin
ERROR: Esys_Sign(0x3E0) - tpm:parameter(3):invalid ticket
ERROR: Unable to run tpm2_sign

The same command against an unrestricted key signs those bytes happily. TPM_RC_TICKET is the refusal that go-tpm-tools is reporting to you when it says restricted keys are not supported.

This one fails at load, and that’s deliberate. The check runs when you attach rather than when you handshake, so a misconfigured key takes down startup instead of one connection an hour later.

tpmtls: no persistent key matches

That’s ErrNotFound, and it means the certificate you’re holding and the contents of the TPM disagree. Either the agent hasn’t provisioned a key yet, or it provisioned a different one than the certificate is over, which is what a stale certificate looks like after re-provisioning. Test for it with errors.Is, then list the handles and compare.

remote error: tls: error decrypting message, on the first read

This one is TLS rather than TPM, and it’s the one that actually caught me.

In TLS 1.3 the client sends its Certificate, its CertificateVerify and its Finished in a single flight, and it does not wait to hear whether any of that was accepted. tls.Dial returns you a working connection either way. If the server refuses your signature it sends an alert, and you meet that alert the next time you read.

So a workload whose identity the far end won’t accept looks exactly like one that connected. If you health check by dialling, you’ve checked that TCP works and that the server’s certificate is valid, and nothing whatsoever about your own. Read a byte:

conn, err := tls.Dial("tcp", addr, cfg)
if err != nil {
	return err
}
defer conn.Close()

// The server's verdict on our certificate arrives as an alert on the next
// read, not as a dial error. Without this the probe passes either way.
if _, err := conn.Write(probe); err != nil {
	return err
}
if _, err := conn.Read(make([]byte, 1)); err != nil {
	return err
}

If you’re going through net/http you get this for free, since any real request reads a response. The trap is specifically a readiness probe that only dials.

what it costs

You pay the TPM once per full handshake and never per request. That single fact is what decides whether any of this is usable.

A TPM signs in milliseconds where software signs in microseconds. I measured how much of that actually reaches a connection in go-tpm-tls-bench, running on Google Cloud Confidential VMs where the vTPM is implemented in hypervisor software. What follows is an n2d-standard-2 with AMD SEV-SNP and a P-256 key, single-threaded, measured against a software key of the same curve:

scenariokeymedianp95throughputsignatures
raw signing, 100 signaturesTPM, persistent2.21 ms2.61 ms452 sig/s100
software0.06 ms0.08 ms16591 sig/s100
TPM, transient20.44 ms21.20 ms49 sig/s100
50 connections, no resumptionTPM3.35 ms3.83 ms295 conn/s50
software1.38 ms1.51 ms707 conn/s50
50 connections, resumptionTPM0.94 ms1.59 ms856 conn/s1, 49 resumed
software0.95 ms1.13 ms1011 conn/s1, 49 resumed
1 connection, 50 requestsTPM0.02 ms0.03 ms40043 req/s1
software0.02 ms0.02 ms39133 req/s1

The signature is most of a cold handshake, 2.21 ms out of 3.35 ms. A TPM runs one command at a time and signing is serialized behind a lock for that reason, so a machine tops out at a few hundred new mutually authenticated connections per second. That limit is per machine, since each machine has its own TPM, so it doesn’t get better by putting more replicas behind one of them.

Now look at the last two scenarios, where the two keys land within noise of each other. The signature counts explain why: 50 signatures for 50 fresh connections, 1 for 50 resumed ones, and 1 for 50 requests over a reused connection. Nothing about the TPM got faster. The handshake stopped happening.

That ceiling is also an exposure, and I’d think about it before deploying this anywhere public. On a listener anyone can reach, forcing full handshakes is a cheap way to spend a few hundred signatures a second, and since the lock has no timeout, the queue behind it is every handshake in the process. Don’t put a TPM key on a public listener. It belongs on an outbound client, on an internal listener, or behind something that limits handshake rate.

Two results surprised me here, and I only found the second because I measured it wrong the first time.

The transient row is a factor of ten, and fifteen on Intel TDX. The kernel resource manager context-saves transient objects between commands, so every signature pays to swap the key back into the TPM, while a persistent object just sits in the TPM’s own storage. That much I expected to matter, though not by that much.

What I didn’t expect is that the cost is charged to the transport rather than to the object. A persistent key that nothing else is touching signs in 2.08 ms, and the same key signs in 20.04 ms while an unrelated transient object sits loaded on the same file descriptor. That’s the same factor of ten, on a key that was never transient. It stops at the descriptor, where the same object costs 2.10 ms. So asking for a persistent key is necessary and not sufficient, and the part you actually control is what else your own process loads on the connection it signs on. The answer should be nothing. This is also why Open is the call I’d reach for over New: the key owns that descriptor and nothing else in the process can put a transient object on it.

Curve choice only costs you on cold handshakes, and on SEV-SNP it costs less than I expected. P-384 signs in 2.37 ms against P-256’s 2.21 ms on this machine, which is 226 cold handshakes a second against 295. I would not quote that gap to two significant figures, though: an earlier run on the same instance type put it at 16% rather than 7%, so the honest summary is that the premium is small and noisy on this platform. Intel TDX is the one to watch, where the same comparison came out at 65%. Resumption erases the difference either way, which matters if a policy like CNSA 1.0 puts you on P-384.

The P-256 table above is a single run on one n2d-standard-2, and the TDX figures come from a c3-standard-4. Both have a hypervisor-implemented vTPM, and none of it says anything at all about discrete TPM hardware.

what it proves

A handshake signed this way proves the connection comes from the machine whose TPM holds the key, and that it happened now rather than being a replay, since the transcript carries fresh randomness from both sides. Here are three things it does not prove, and I think they matter more than the thing it does.

It says nothing about the code running on that machine. An attacker inside your process can sign for as long as they’re in it, and so can anything else on the box, because the key has no auth value and the access control on it is therefore just whether you can open /dev/tpmrm0, which means root or the tss group. The key is bound to the machine, not to your workload. Narrowing it to one process is a separate problem and the TPM doesn’t solve it.

It doesn’t keep that attacker out of your traffic either. The session keys are ordinary memory. What stays in the TPM is the long-term identity, not the bytes on the connection.

And it depends where the vTPM itself lives, which is a platform choice rather than something confidential computing settles for you. SEV-SNP encrypts guest memory against the host; it says nothing about who implements the TPM. On the machine I measured these numbers on, Google’s, the vTPM is hypervisor software, so the key is out of reach of everything in the guest and not out of reach of the platform. The tell is a single line of dmesg, SEV: SNP running at VMPL0., meaning the kernel holds the most privileged level itself, so nothing is running beneath it keeping a vTPM on its behalf.

The other arrangement puts a small trusted component at VMPL0 and the guest kernel at VMPL1, and that component serves the vTPM from inside the encrypted guest. Azure does this with its paravisor, and COCONUT-SVSM is the open version. There the host cannot read the TPM’s state, at the price of putting that component inside your TCB, where attestation had better be measuring it. Same device, same code above it, and a completely different answer to who could walk off with the key. I’d find out which one you’re on before you describe this property to anyone.

wrapping up

The claim this ends up making is a narrow one. None of it stops an attacker on the machine from using the key; while they are there, they can sign whatever they like. What they can’t do is take it with them, so their use of it ends when their access does. The key can still be misused. It cannot leave.

If you take one thing from this, I’d rather it be the reframing than the library. A private key in a file is a bearer credential that happens to sit on a machine. A key in the TPM is closer to a property of the machine itself, and that difference is what you’re actually buying. It’s also the only answer to post-attestation machine identity I’ve found that doesn’t quietly give back what the attestation just established.

What’s missing is the link between the key and the attestation, and the shape it’s taking is SPIFFE. Roughly: the vTPM’s public key goes up alongside the evidence, and a short-lived certificate over that same key comes back down, an X.509-SVID. The private key never moves. The certificate expires and gets reissued while the key underneath it stays put, which is the same split as earlier in this post.

The mTLS at the far end of that is the code you’ve already read here, pointed at a different CA. I’m writing it up next.

Working notes. If something here is wrong, tell me.