The Change in Trust Model
Every certificate authority ships with an implicit trust boundary. On one side: the CA, its private key, its policy engine. On the other: the CSR and whatever authenticated the request. Anything that gets across that boundary comes back as a signed certificate. Traditional PKI decides who gets across with credentials — an API key, an EST bootstrap cert, an ACME account key.
TPM-attested issuance changes what "authorized" means. It's no longer "you presented valid credentials." It's "your Trusted Platform Module produced a fresh, signed proof that (a) the device is in an expected firmware state, (b) the AK doing the signing belongs to a manufacturer-certified TPM, and (c) the CSR public key is provably inside that same TPM."
Miss any of those and the CA refuses. Not "logs a warning and signs anyway" — refuses.
This post is the end-to-end architecture walk-through for anyone building or operating attested issuance. We'll go from the moment an agent decides to renew, through nonce issuance, evidence collection inside the TPM, backend policy resolution, verifier checks in the CA, and the audit row that gets written either way.
The three actors
Attested issuance runs across three services:
+-------------------+ +-----------------+ +--------------------+
| Device / Agent | | Node.js Backend | | PKI Core |
| (services/agent) | | (apps/backend) | | (services/pki-core)|
| | | | | |
| - go-tpm client | | - Policy CRUD | | - Verifier |
| - TPMProvider | | - Nonce store | | - Issuance gate |
| - TPM2_Quote | | - Audit trail | | - CA operations |
| - TPM2_Certify | | | | |
| - ActivateCred | | | | |
+-------------------+ +-----------------+ +--------------------+
agent traffic ------> HTTPS/API keys ----> HTTPS/mTLS
- The agent runs on the device and talks to
/dev/tpmrm0viagithub.com/google/go-tpm. It never sees the AK or EK private material — those never leave the TPM. - The backend owns policy CRUD (
attestation_policies), nonce storage (attestation_nonces), and the enrolled-AK table (enrolled_aks). It's the gate in front of the CA. - PKI Core hosts the verifier (
services/pki-core/internal/attestation) and refuses to sign a CSR whose evidence bundle fails any check.
The split matters: the backend runs policy in TypeScript with the workspace context; the CA runs cryptographic verification in Go without any I/O so it's simulator-testable. Neither can bypass the other.
Step 1: Agent requests a nonce
The agent decides it's time to renew (cron, expiry watcher, config change — pick your trigger). It calls:
POST /api/iot/devices/:id/attestation/challenge Authorization: Bearer <device-api-key> # response { "nonce": "3f9a2b4c8d1e5f7a...", "expiresAt": "2026-08-25T15:42:00Z", "purpose": "attest-and-provision" }
The nonce is hex-encoded, single-use, and pinned to a five-minute TTL. It's the freshness anchor for everything that follows — every signed structure in the evidence bundle will include this exact nonce as ExtraData.
Why a fresh nonce every time? Because without one, an attacker who once captured a valid evidence bundle could replay it forever. The nonce turns the whole flow from "here's a signed bundle" into "here's a signed bundle that could only have been produced right now, in response to this specific challenge."
The backend stores the nonce with its expiry and device ID. A background reaper (attestation-nonce-reaper worker) purges expired nonces every 15 minutes. Reused nonces are rejected atomically — first consumer wins, everyone else gets nonce_already_used.
Step 2: Agent builds the evidence bundle inside the TPM
This is where the TPM does real work. The agent, using Go's go-tpm and go-tpm-tools packages:
// Pseudocode — actual implementation in services/agent/internal/keyprovider/tpm_provider.go pcrValues, _ := tpm2.PCRRead(tpm, tpm2.PCRSelection{ Hash: tpm2.AlgSHA256, PCRs: []int{0, 1, 2, 3, 4, 7}, }) quote, quoteSig, _ := ak.Quote( pcrSelection, nonce, // ExtraData ) certifyInfo, certifySig, _ := tpm2.Certify( tpm, signingKey.Handle, ak.Handle, nonce, ) boundArea := signingKey.PublicArea().Encode() csr, _ := x509.CreateCertificateRequest( &x509.CertificateRequest{ Subject: subject }, signingKeySigner, )
Six artifacts come out of this:
pcrValues— the actual PCR digests for the banks and indices the policy cares about (typically SHA-256 across PCRs 0/1/2/3/4/7). Not signed on their own; they're claimed values.quote(TPMS_ATTEST) — a structured summary of PCR state, signed by the AK, with our nonce as ExtraData. This is the proof-of-state.quoteSig— the AK's signature over the quote. RSA-PKCS1v15, RSA-PSS, or ECDSA depending on the AK's algorithm.certifyInfo(TPMS_ATTEST) — a signed statement from the AK about the signing key: "the key with this Name lives in this TPM and has these attributes."certifySig— the AK's signature over certifyInfo, with our nonce as ExtraData.boundArea(TPM2B_PUBLIC) — the raw public area of the signing key, used to cross-check that the CSR's public key matches what the AK certified.
The CSR itself is standard X.509. What makes it different from a normal CSR is that the private key it's signed by cannot leave the TPM. It was generated inside the TPM with non-migratable attributes; the signing operation happens inside the TPM.
Step 3: Agent POSTs everything to the backend
One request carries everything the verifier needs:
POST /api/iot/devices/:id/attest-and-provision Authorization: Bearer <device-api-key> { "caId": "prod-edge-issuing-ca", "templateId": "edge-gateway-mtls", "csr": "-----BEGIN CERTIFICATE REQUEST-----\n...", "evidence": { "akPub": "AAEACwAEBHIA...", "ekCert": "-----BEGIN CERTIFICATE-----\n...", "quote": "/1RDR4AYACIACw...", "quoteSig": "AAgABAAI...", "pcrValues": { "SHA256": { "0": "ab34c1a2b5...", "1": "c44d...", "2": "91e7...", "3": "0000000000000000000000000000000000000000000000000000000000000000", "4": "77b2...", "7": "9d1543e6..." } }, "certifyInfo": "/1RDR4AYACIAC...", "certifySig": "AAgABAAI...", "boundPubPem": "-----BEGIN PUBLIC KEY-----\n...", "boundPublicArea": "AAEACwAEBHIA...", "nonce": "3f9a2b4c8d1e5f7a..." } }
The agent's job is basically done. The rest of the story is server-side.
Step 4: Backend consumes the nonce and resolves policy
The backend runs a compact set of steps before it forwards anything to the CA:
// apps/backend/modules/iot.ts::/attest-and-provision (paraphrased) const nonceRow = await storage.consumeAttestationNonce(deviceId, evidence.nonce); if (!nonceRow) throw new HTTPError(403, 'nonce_invalid_or_expired'); const policy = req.body.policyId ? await storage.getAttestationPolicy(req.body.policyId) : (await storage.getAttestationPolicies(workspaceId, device.deviceType)) .find((p) => p.enabled); const result = await pkiCore.signAttested({ caId, templateId, csr, evidence, policy, }); await storage.recordDeviceAttestation({ deviceId, policyId: policy?.id, passed: result.passed, reasons: result.reasons, issuedCertId: result.certificateId, }); return result;
The nonce is consumed atomically — first consumer wins. That means a request with a previously used nonce fails immediately with nonce_already_used; it never reaches the CA. Policy resolution follows a specific precedence: explicit policyId in the request body wins; otherwise the first enabled policy matching device.deviceType; otherwise, run verifier cryptographic checks without policy gating (used only for bootstrap — real deployments should always have a policy).
Every attempt gets a device_attestations row before returning, with the resolved policyId stamped in. Audit reconstruction is trivial: "which policy was in effect when this cert was issued?" is one SQL query away.
Step 5: PKI Core verifier runs every check
The verifier is a plain Go package with no I/O — pure functions over the evidence bundle and policy. This is deliberate: it's unit-testable against github.com/google/go-tpm-tools/simulator, and there's nowhere for a bug to hide behind a network call. Verify(evidence, policy) (*Result, error) runs every check and returns a structured result.
The checks, in order:
5.1 AK signature on the quote. Verify that quoteSig is a valid signature over the bytes of quote (which is a serialized TPMS_ATTEST), using the AK public key extracted from akPub. Supports RSA-PKCS1v15, RSA-PSS, and ECDSA. Fail code: quote_verify: AK signature invalid.
5.2 Nonce in ExtraData. Decode the TPMS_ATTEST, extract ExtraData, and compare to the nonce the backend just consumed. Mismatch means replay or agent bug. Fail code: quote_verify: nonce mismatch.
5.3 PCR digest matches claimed values. The TPMS_ATTEST contains a PCRDigest field — a SHA-256 over the concatenation of the quoted PCR values. Compute SHA256(concat(pcrValues)) and compare. If they disagree, the agent claimed one set of PCRs but signed a quote over different ones. Fail code: quote_verify: pcr digest does not match claimed PCR values.
5.4 Required PCRs present. For every index in policy.requiredPcrs, confirm it's in pcrValues. Missing entries fail with pcr_missing:<n> — this catches agents that cherry-pick only PCRs they know are green.
5.5 Expected PCRs match. For every (bank, index) -> expectedHex in policy.expectedPcrs, compare against the actual PCR value. Mismatch fails with pcr_mismatch:<n>.
5.6 Secure Boot check. If policy.requireSecureBoot, confirm PCR7 is non-zero. A zero PCR7 means Secure Boot didn't measure its state, which means it either didn't run or ran without a signature database. Fail code: secure_boot_not_measured.
5.7 Certify signature valid. Verify certifySig over the bytes of certifyInfo, using the same AK public key. Fail: certify_verify: AK signature invalid.
5.8 CertifyInfo Name matches bound public area. The certifyInfo contains a Name digest — the canonical identifier of the key the AK certified. Compute SHA256(boundPublicArea) and compare. Mismatch means the AK certified a different key than the one whose public area the agent submitted. Fail: certify_verify: certify name digest does not match bound public area.
5.9 Bound public area matches submitted PEM. Decode boundPublicArea (TPM2B_PUBLIC) and extract the RSA modulus/exponent or ECC point. Compare byte-for-byte against boundPubPem. This is the CSR-swap detector — the AK certified key X, the agent submitted key X, but if the CSR is actually over key Y, this comparison fails. Fail: certify_verify: bound public key modulus/exponent does not match.
5.10 CSR public key matches bound public key. Extract the CSR's public key and confirm it matches boundPubPem. The CSR is what the CA will sign, so this closes the loop: the AK certified this specific key, and this specific key is what the CSR carries.
5.11 EK chain verification. Parse ekCert, walk it up to a root in the manufacturer trust bundle. If the bundle is empty (dev mode) this step is skipped with a warning. In production, failure returns ek_chain: <details>.
5.12 EK manufacturer allow-list. Extract the manufacturer from the EK certificate issuer and check against policy.allowedEkManufacturers. Empty allow-list = any manufacturer (as long as chain-verify passes). Fail: ek_manufacturer_not_allowed:<mfr>.
5.13 Enrolled AK lookup. Compute the AK Name (SHA256(akPub)) and confirm there's a matching row in enrolled_aks. An AK that never went through Credential Activation fails with ak_not_enrolled.
Every check that passes contributes to Result.Passed = true. Every failure appends a reason code. The CA signs only if Result.Passed is true.
Step 6: Sign — or deny with a reason
On success, PKI Core:
- Applies the certificate template (subject, extensions, validity, key usage).
- Signs the CSR with the issuing CA's key (HSM-backed in production).
- Returns the certificate and chain to the backend.
The backend stamps issuedCertId into the device_attestations row and returns the certificate to the agent.
On failure, PKI Core returns HTTP 403 with a structured body:
{ "error": "attestation_failed", "reasons": [ "pcr_mismatch:7", "certify_verify: bound public key modulus/exponent does not match" ] }
The backend records the failure with reasons and returns the same body to the agent. Agents surface the reason to their monitoring pipeline; operators can alert on specific codes.
The full reason-code catalog
Because every failure is machine-readable, dashboards and alerts stay clean. The complete set:
| Failure code | Meaning |
|---|---|
quote_verify: AK signature invalid | AK didn't actually sign the quoted TPMS_ATTEST |
quote_verify: nonce mismatch | Replay attack or agent bug |
quote_verify: pcr digest does not match claimed PCR values | Agent lied about PCR contents |
pcr_missing:<n> | Required PCR not included in quote |
pcr_mismatch:<n> | PCR value differs from policy expectation |
secure_boot_not_measured | PCR7 is zero or absent |
certify_verify: certify name digest does not match bound public area | Bound key doesn't correspond to attested key |
certify_verify: bound public key modulus/exponent does not match | Attempted CSR-key swap |
ek_chain: ... | EK cert fails to chain to a trusted manufacturer root |
ek_manufacturer_not_allowed:<mfr> | EK signed by manufacturer not in policy |
ak_not_enrolled | AK Name has no row in enrolled_aks |
nonce_expired / nonce_already_used / nonce_device_mismatch | Freshness violation |
What a working audit trail looks like
Every attempt writes a row into device_attestations, whether it passed or failed:
SELECT device_id, policy_id, passed, reasons, issued_cert_id, created_at FROM device_attestations WHERE device_id = 'gw-042' ORDER BY created_at DESC LIMIT 5;
Result:
device_id | policy_id | passed | reasons | issued_cert_id | created_at
----------|-----------|--------|----------------------------------|----------------|-------------
gw-042 | 3 | true | [] | cert-77821 | 2026-08-25 14:37:02
gw-042 | 3 | false | ['pcr_mismatch:0'] | null | 2026-08-25 14:32:18
gw-042 | 3 | true | [] | cert-77532 | 2026-08-24 14:37:00
gw-042 | 3 | true | [] | cert-77241 | 2026-08-23 14:37:04
gw-042 | 3 | true | [] | cert-76920 | 2026-08-22 14:37:01
The failure between two successes tells a story on its own: PCR0 shifted — likely a firmware push. The operator checks whether the firmware push was authorized, updates expectedPcrs.SHA256.0 if so, and rolls fleet-wide.
Deployment checklist
To turn this on for a real fleet:
Server side:
- Populate
ek_trust_bundle_dirwith the manufacturer roots for the TPM vendors you ship. - Set
PKI_CORE_URLin the Node backend.env(default:http://localhost:8443). - Run
npm run db:pushto addattestation_policies,attestation_nonces,enrolled_aks, and the new columns ondevice_attestations. - Confirm the
attestation-nonce-reaperworker is running (auto-started byapps/backend/workers/index.ts). - Author at least one attestation policy per device type. Start with cryptographic-only (no
expectedPcrs) to observe the fleet, then progressively pin.
Agent side:
- Load the
tpm_rmdriver on the device so/dev/tpmrm0exists. - Configure the key provider:
keyprovider: type: tpm tpm_device_path: /dev/tpmrm0 keystore_path: /var/lib/tigertrust/tpm
- Confirm the agent process has read access to the manufacturer EK cert (NV index
0x01C00002). - Run
tigertrust-agent enroll-tpm --backend <url> --device-id <id>once per device. - From then on,
tigertrust-agent renew-certruns the full flow automatically.
Why "no I/O in the verifier" matters
One design choice that pays dividends: the verifier is a pure function. It doesn't hit a database, doesn't talk to the network, doesn't call the CA. That means:
- Every code path is unit-testable against a TPM simulator.
- The negative cases (wrong AK, wrong EK, tampered blob, replayed nonce, cherry-picked PCRs) are easier to reproduce in CI than with real hardware.
- Bugs can't hide behind async race conditions or flaky network calls.
- Auditors reviewing the verifier only need to reason about one file, not a subsystem.
The E2E tests in services/pki-core/internal/attestation/e2e_test.go::TestVerify_E2E_Simulator{,_Rejections} cover every path — including all the rejection reasons. If a bug slips through, it's not because a path wasn't tested.
Threat model sanity check
Attackers this architecture defends against:
- Stolen CSR + credentials. Cannot forge a fresh
TPM2_Quotewith matching PCR digest and valid AK signature. - Malware on the device. Cannot extract the signing key from the TPM to use elsewhere; PCRs change (typically PCR9/10 for IMA-instrumented Linux) and the quote fails.
- CSR-swap. Verifier compares CSR pubkey to
boundPubPemAND to the RSA/ECC fields insideboundPublicAreaAND requires the AK-signed Certify to reference that exact public area. - AK spoofing. Credential Activation binds the AK to the manufacturer EK cert.
- Nonce replay. Nonces are single-use, expire in 5 minutes, and consumed atomically.
- Rogue TPM manufacturer. Mitigated by curating the EK trust bundle.
Out of scope: physical extraction of TPM contents, and firmware rootkits that pre-date PCR0 measurement (mitigated by pinning expectedPcrs.SHA256.0 to a known-good firmware digest).
Wrap-up
TPM-gated issuance is what happens when a CA stops trusting the request and starts trusting the hardware behind it. The mechanism isn't new — TCG has specified TPM 2.0 attestation for years. The engineering work is in gluing the pieces together into a flow where nonce, quote, certify, EK chain, and policy all get consulted before the CA signs, and where failure is a structured refusal with a reason code, not a silent warning.
Once it's wired up, the CA doesn't just issue certificates. It issues certificates that come with a receipt — every issuance stamped with the policy that gated it, the PCRs that passed, and the AK Name that signed the evidence. Every denial stamped with the exact reason it was denied. That's the difference between "we hope the right devices get certificates" and "we can prove the right devices got certificates."
Want to see the full flow run against a real TPM in your environment? Book a demo or talk to the engineering team.