Why We Need Credential Activation at All
The Attestation Key (AK) is the workhorse of TPM 2.0 remote attestation. It signs TPM2_Quote structures, it signs TPM2_Certify structures, it's what the verifier ends up trusting. So the obvious question is: how does the verifier know the AK it's trusting was actually generated inside the TPM it thinks it was?
The AK's public part is just bytes on the wire. Anyone can generate an RSA or ECC key on a laptop, format it as TPM2B_PUBLIC, and mail it in claiming "this is my Attestation Key." The verifier has no built-in way to tell a real TPM-resident AK from a soft-key fabricated in Go on someone's MacBook.
Credential Activation is the TCG-standard protocol that answers this. It uses the manufacturer-issued Endorsement Key (EK) as an anchor: the CA sends a challenge that can only be recovered by a TPM that physically holds both the exact EK the manufacturer certified and the exact AK whose Name went into the challenge. There is no way to fake it externally.
This post walks through the protocol at the TCG-spec level, explains why each piece is necessary, and shows how a production implementation wires it together end-to-end.
The two keys involved
Before the protocol, two facts about the keys:
Endorsement Key (EK) — the TPM's factory identity. The private half is generated inside the TPM at manufacture and never leaves. The public half is packaged into an X.509 certificate signed by the TPM manufacturer (Intel PTT, Infineon SLB, ST ST33, Nuvoton NPCT, AMD fTPM, IBM, or Microsoft Virtual TPM). The EK cannot sign arbitrary data — its scope is deliberately limited to key exchange (TPM2_ActivateCredential, key duplication). This restriction is what makes the EK safe to use as a long-term identity.
Attestation Key (AK) — a signing key generated inside the TPM for the purpose of signing attestation structures. Unlike the EK, an AK is not fixed at manufacture; a TPM can create as many AKs as needed. Also unlike the EK, an AK can sign — it's a signing key. But an AK is only trustworthy if you can prove it lives in the same TPM as a known-good EK.
Credential Activation is that proof.
The protocol, from first principles
The whole thing is a challenge-response with a specific cryptographic twist: the challenge is wrapped so that only the TPM holding the EK can unwrap it, and the wrapping is bound to the specific AK we're trying to activate.
Here's the sequence:
agent pki-core
| |
| POST /attestation/enroll/challenge |
| { ekCertificatePem, akPublicAreaBase64 } -> |
| | parse EK cert -> EK pub
| | chain-verify against
| | manufacturer trust store
| | decode TPM2B_PUBLIC -> AK Name
| | gen random 32B secret
| | credactivation.Generate(
| | akName, ekPub,
| | symBlockSize, secret)
| | -> (credBlob, encSecret)
| <- { challengeId, | store SHA256(secret)
| credentialBlobBase64, | + AK area + EK cert
| encryptedSecretBase64 } | (TTL 5min)
| |
| TPM2_ActivateCredential( |
| AK.Handle, EK.Handle, |
| credBlob, encSecret) |
| -> secret |
| |
| POST /attestation/enroll/response |
| { challengeId, recoveredSecretBase64 } ---> |
| | SHA256(recovered) == stored?
| <- { enrolled: true, ... } | yes -> persist enrolled AK
| | no -> 403 enrollment_failed
Two round trips: challenge and response. Everything else is cryptographic mechanism.
Step by step: what actually happens
Step 1: Agent submits the challenge request.
The device agent has two artifacts to send:
- The EK certificate, read from TPM NV storage (typically NV index
0x01C00002on Linux). This is the manufacturer-signed identity. - The AK's
TPM2B_PUBLICstructure — the canonical wire format for a TPM public key, including all the algorithm parameters and object attributes.
The request looks like:
{ "ekCertificatePem": "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", "akPublicAreaBase64": "AAEACwAEBHIA..." }
Step 2: Server chain-verifies the EK certificate.
The verifier walks the EK certificate up to a trusted TPM manufacturer root. If your manufacturer trust bundle directory (ek_trust_bundle_dir) contains, say, the Infineon SLB root, an Infineon-issued EK chains cleanly. An unknown EK fails with an ek_chain reason code and the whole enrollment is rejected.
This is the "is this the TPM we think it is?" gate. Skip it and any Go simulator can pass enrollment.
Step 3: Server computes the AK Name.
The TPM 2.0 spec defines an object's "Name" as SHA256(TPM2B_PUBLIC) — a canonical, algorithm-tagged digest of the full public area. Two AKs with different attributes have different Names even if they share the same modulus, because attributes are part of the digest.
The AK Name is what Credential Activation ties the challenge to.
Step 4: Server generates the credential blob.
The server produces a random 32-byte secret, then calls the TCG-standard credactivation.Generate (or the equivalent in Go's go-tpm-tools):
Generate(akName, ekPub, symBlockSize, secret)
-> (credBlob, encSecret)
Under the hood:
- The secret is wrapped in an HMAC structure keyed by material derived from the AK Name. This ensures the wrapped secret is specifically bound to this AK — using a different AK produces a different derivation and the HMAC check on the TPM side fails.
- The whole wrapped structure is then encrypted to the EK's public key using either RSA-OAEP or ECIES, depending on the EK algorithm.
The two outputs — credBlob (the AK-Name-bound wrapping) and encSecret (the EK-wrapped seed used to derive the symmetric key) — go back to the agent.
Step 5: Server stashes the expected answer.
The server does not store the secret in plaintext. It stores SHA256(secret) plus the AK area and the EK certificate, tagged with a challengeId and a 5-minute TTL. When the agent returns the recovered secret, the server hashes it and compares.
Step 6: Agent runs TPM2_ActivateCredential on the TPM.
The agent hands credBlob and encSecret to the TPM along with handles for both the EK and the AK:
secret = TPM2_ActivateCredential(
activateHandle = AK.Handle,
keyHandle = EK.Handle,
credentialBlob = credBlob,
secret = encSecret
)
Inside the TPM, three things happen:
- The TPM uses the EK's private key to decrypt
encSecretinto a symmetric seed. - The seed is expanded (per TCG spec KDF) into a symmetric key.
- That symmetric key decrypts
credBloband validates the HMAC against the AK's Name.
If the AK the TPM was asked to activate against isn't the same AK the server used to derive the wrapping, the HMAC check fails inside the TPM and no secret is returned. If the caller doesn't hold the correct EK, the RSA-OAEP or ECIES decryption fails at step 1.
Only a TPM that holds both the correct EK and the correct AK ever sees the plaintext secret.
Step 7: Agent returns the recovered secret.
{ "challengeId": "abcd1234...", "recoveredSecretBase64": "hVxq..." }
Step 8: Server verifies and persists.
recovered = base64_decode(recoveredSecretBase64)
if SHA256(recovered) == stored_hash:
persist enrolled_ak {
ak_name = SHA256(TPM2B_PUBLIC)
ak_public = TPM2B_PUBLIC bytes
ek_cert = full EK certificate
ek_mfr = extracted from EK issuer
enrolled_at = now
}
return { enrolled: true, ... }
else:
return 403 enrollment_failed
The AK Name is the canonical identifier for the AK going forward. Every subsequent attestation looks up the AK Name from the presented quote and confirms it matches an enrolled_aks row.
Why this specific design works
Skeptical engineers correctly ask: why is this construction secure? Two properties matter.
EK wrapping proves "I hold the manufacturer-certified private key." Only a party holding the EK private key can decrypt encSecret. Since the EK private half was generated inside the TPM at manufacture and cannot be exported, the only entities on Earth that hold it are (a) that specific TPM and (b) hypothetically the manufacturer at production. In practice, (b) is not a threat in the field.
AK-Name binding proves "I'm activating against the specific AK you challenged." The credential blob is HMAC'd with a key derived from the AK Name. If you present a different AK's handle to TPM2_ActivateCredential — including a different AK from the same TPM — the derivation produces a different key, the HMAC verifies fails, and the TPM refuses to return the secret. This prevents an attacker who owns a real TPM from swapping in an AK of their choice at activation time.
Together: only a TPM holding both the specific EK and the specific AK can return the secret. There is no known way to satisfy both constraints externally.
What the agent side looks like
Concretely, the Go agent uses github.com/google/go-tpm and github.com/google/go-tpm-tools:
# One-shot enrollment on a device tigertrust-agent enroll-tpm \ --backend https://tigertrust.example.com \ --device-id gw-042
Under the hood, the agent:
- Opens the TPM resource manager (
/dev/tpmrm0on Linux with thetpm_rmdriver loaded). - Reads the EK certificate from NV index
0x01C00002. - Generates or loads an AK inside the TPM (non-migratable, restricted signing).
- POSTs the challenge request.
- Calls
TPM2_ActivateCredentialwith the AK and EK handles and the returned blobs. - POSTs the recovered secret.
- Persists the enrollment context (handle chain, key files) under
keystore_path.
Successful backend logs look like:
[Attestation] Nonce issued for device gw-042
[Attestation] Enrollment challenge issued
[Attestation] AK enrolled -- Name=<sha256>, EK=Infineon
From here on, every tigertrust-agent renew-cert runs the full attest-and-provision flow against the enrolled AK.
What gets persisted
On success, the backend writes to enrolled_aks:
- AK Name (SHA-256 of TPM2B_PUBLIC — the canonical AK identifier)
- AK public area (TPM2B_PUBLIC bytes, for signature verification on later quotes)
- Full EK certificate (for audit and future chain re-verification)
- Detected EK manufacturer (extracted from the EK certificate issuer, used for the policy allow-list at attestation time)
- Enrolled-at timestamp and enrolling operator (via UI) or device ID (via agent)
An AK that isn't in enrolled_aks fails immediately with ak_not_enrolled on any subsequent attestation. This is by design: the enrollment step is the only point where the AK-EK binding gets established, and every downstream check trusts that binding.
Re-enrollment: no shortcuts
If a device's TPM is cleared or ownership changes, the AK is effectively new. The operator deletes the enrolled_aks row and re-runs tigertrust-agent enroll-tpm. The new AK gets its own Credential Activation cycle — same protocol, same guarantees.
There's no "trust the operator" shortcut. Every AK enters through the same challenge/response gate.
The trust bundle is not optional in production
The verifier optionally chain-verifies the EK certificate against a directory of manufacturer roots:
server: ek_trust_bundle_dir: /etc/tigertrust/ek-roots
Any .pem, .crt, or .cer file in the directory is loaded. TPM vendors distribute their roots under their own licenses, so TigerTrust does not vendor them:
| Manufacturer | Fetch from |
|---|---|
| Intel PTT | https://ekop.intel.com/ekcertservice |
| Infineon SLB | https://pki.infineon.com/ |
| STMicro ST33 | https://sw-center.st.com/STSAFE/ |
| Nuvoton NPCT | https://www.nuvoton.com/security/NTC-TPM-EK-Cert/ |
| AMD fTPM | https://ftpm.amd.com/ |
| IBM | https://www.ibm.com/support/pages/tpm-endorsement-certificate-check-tool |
| Microsoft Virtual TPM | Available via TPM.msc export |
If the directory is empty or missing, the verifier logs a warning and skips EK chain checks (dev-friendly). Quote, Certify, and PCR-policy checks still run — but you've lost the "is this the TPM we think it is?" proof, and any TPM simulator will happily enroll. In production, populate the directory.
Common failure modes and what they mean
Because every failure code is machine-readable, operators can build alerts and dashboards. The enrollment failures you'll actually see:
| Failure code | Root cause |
|---|---|
ek_chain: unable to find issuer for EK cert | Missing manufacturer root in trust bundle |
ek_chain: EK cert expired | Very old device with an expired EK cert; contact vendor |
enrollment_failed: secret hash mismatch | Agent returned the wrong secret — usually a bug in Activate, wrong EK handle, or a compromised path |
enrollment_failed: challenge expired | Enrollment took >5 minutes; retry |
enrollment_failed: invalid ak public area | TPM2B_PUBLIC bytes malformed or algorithm-mismatched |
Structured codes are a lot easier to alert on than "internal server error."
Where the pieces live
For anyone who wants to dig deeper, the implementation files:
- Verifier logic:
services/pki-core/internal/attestation/enrollment.go - Agent side:
services/agent/internal/keyprovider/tpm_provider.go->ActivateAKCredential - HTTP endpoints:
services/pki-core/internal/api/enrollment.go - E2E test:
services/pki-core/internal/attestation/enrollment_test.go::TestCredentialActivation_E2E
Every path is exercised against github.com/google/go-tpm-tools/simulator in CI, including the negative cases (wrong AK, wrong EK, tampered blob) that are hard to reproduce with real hardware.
Wrapping up
Credential Activation is the least glamorous, most load-bearing step in TPM attestation. It happens exactly once per device, it produces no user-visible artifact, and it makes every subsequent attestation meaningful. Without it, the AK signing your quotes is just a public key someone claimed came from a TPM — and the verifier has no basis to believe them.
With it, you get: "this AK was generated inside a TPM whose Endorsement Key was signed by a manufacturer we trust." That single guarantee is what turns TPM2_Quote from a claim into a proof.
If you're building attested certificate issuance and skipping Credential Activation to save an API call, stop. It's the anchor that makes everything downstream true.
Want to see enrollment run end-to-end on your hardware? Book a demo or talk to the team.