Skip to main content
Version: v0.1.0-rc.1

🗝️ How keys never leave the TPM

Following the private key from creation to signing without it ever touching disk.

On a node built with the default tpm state-key mode, the CA key is created inside the TPM, is marked non-exportable by the TPM itself, and signs only inside the TPM. The rest of this page shows where that holds and where it doesn't.

First, which key and which mode​

The claim in the title holds for one configuration. The image is built with a state-key mode (STATEKEY=tpm|nodeid at build time, and state_key.mode in the machine config can pick tpm, nodeid or kms). The mode decides where the CA key lives:

State-key modeState volume keyCA key backendCA key algorithmsCA key exportable
tpm (default)Sealed to the TPM under PCR 7 and PCR 11TPM (tpmRootBackend)ECDSA-P384, and RSA-3072 or RSA-4096 where the TPM implements the sizeNo
nodeidDerived from the SMBIOS product UUIDSoftware (softRootBackend)ECDSA-P384, RSA-3072, RSA-4096Yes
kmsWrapped by an external KMSSoftware (softRootBackend)ECDSA-P384, RSA-3072, RSA-4096Yes
Only the tpm mode keeps the CA key in hardware

In the nodeid and kms modes the CA key is generated in process memory with Go's crypto/rand and stored as a plain DER key inside the encrypted state volume. The code calls this backend "NOT hardware-protected. Dev tier only." In nodeid mode the volume key comes from the SMBIOS UUID, which is not a secret, so the encryption binds the data to the machine but doesn't keep a determined attacker with the disk out. Use the tpm image for any CA you rely on.

An RSA CA key in the TPM needs a TPM with that size

The TPM 2.0 spec only requires a TPM to implement RSA-2048, and many parts stop there, below the RSA-3072 floor for a CA key. Before creating an RSA key, CreateKey asks the TPM with TPM2_TestParms whether it implements the size. If it doesn't, key creation stops with an error containing tpm: key algorithm not supported by this TPM: RSA-3072 (or RSA-4096) and nothing is created. The ceremony and CA-key rotation return it as FailedPrecondition; an intermediate or issuing node's boot stops on it. The node never falls back to a smaller size or to a software key. Check the TPM's datasheet before the ceremony, or use the nodeid or kms image, where the key is a software key.

From here on, this page covers the tpm mode.

Boot: the TPM has to be able to do the job​

PID 1 opens /dev/tpmrm0 (the kernel's resource-managed TPM device) and asks it for its supported curves with TPM2_GetCapability(TPM_CAP_ECC_CURVES), and for the RSA sizes it accepts (2048, 3072, 4096) with TPM2_TestParms. It logs both on a line starting init: TPM capabilities:. If the TPM can't be opened the boot fails with a hint to use the nodeID image; if it doesn't list NIST P-384 the boot fails with init: TPM does not advertise ECDSA P-384. A node never falls back to a software key on its own.

Creation: the key is born inside the TPM​

When the ceremony runs, two TPM objects are involved.

The Storage Root Key (SRK). ProvisionSRK creates a primary key in the owner (storage) hierarchy from the TCG reference ECC P-256 SRK template and makes it persistent at handle 0x81000001. If an object already lives at that handle it is reused, so the call is idempotent. The SRK never leaves the TPM; it only wraps other keys.

The CA key. CreateKey runs TPM2_Create under the SRK with this template (publicTemplate in internal/tpm/key.go):

Template fieldValueWhat it means
Type, curveECC, TPM_ECC_NIST_P384An ECDSA P-384 key.
SchemeECDSA with SHA-384The TPM signs only with this scheme.
Type, size (RSA instead)RSA, 3072 or 4096 bits, exponent 65537An RSA key. RSA-2048 is refused.
Scheme (RSA)noneEach signing call picks PKCS#1 v1.5 or PSS and the SHA-2 hash.
sensitiveDataOriginsetThe TPM generated the private key itself; it was never supplied from outside.
fixedTPMsetThe key can't be duplicated to another TPM.
fixedParentsetThe key can't be re-wrapped under another parent.
signsetA signing key.
userWithAuthset, with an empty auth valueUse is authorized by an empty password, not by a policy.

The RSA key gets exactly the same five attributes as the ECDSA key.

TPM2_Create returns four things, and these are all that ever leave the TPM:

  • TPM2B_PRIVATE: the private key encrypted and integrity-protected by the SRK. It can only be loaded back into this TPM, under this SRK.
  • TPM2B_PUBLIC: the public key and the template above.
  • TPM2B_CREATION_DATA and the creation ticket: the TPM's record of the conditions the key was created under.

The ceremony streams the public blob and creation data in its KEY_CREATED event and records all three pieces of evidence in the ceremony manifest. The two key blobs are written, in the ceremony's single etcd transaction, to /cryptos/identity/root/key-blob and /cryptos/identity/root/key-public. etcd's data lives in /var/lib/cryptos/etcd, on the encrypted state volume.

The plaintext private key exists only inside the TPM. No code path in internal/tpm can read it back, and the attributes above tell the TPM to refuse if one tried.

Signing: load, sign, flush​

The CA key is not kept loaded. Every operation that needs it (issuing a leaf, signing a subordinate CSR, building a CRL, minting the OCSP responder certificate, answering Attest) goes through one key loader in internal/init/run.go:

  1. Read the two blobs from etcd.
  2. TPM2_Load them under the SRK. The TPM decrypts the private part internally and returns a transient handle.
  3. Go's crypto/x509 builds the structure to sign and hashes it on the host: SHA-384 for an ECDSA key, and for an RSA key the SHA-2 hash its signature algorithm names.
  4. Key.Sign sends only the digest to TPM2_Sign. For ECDSA it must be 48 bytes; the TPM returns r and s, which Key.Sign DER-encodes as the SEQUENCE { r, s } that crypto/x509 expects. For RSA the hash and the padding (PKCS#1 v1.5, or PSS) come from the caller's signer options, the TPM pads and signs, and Key.Sign checks the signature against the public key before it returns it unwrapped. A digest whose length doesn't match its hash is refused.
  5. TPM2_FlushContext releases the transient handle.

The randomness for each ECDSA signature, and each PSS salt, comes from the TPM, not from the host. Certificate construction stays in the Go standard library, and go-tpm only marshals TPM commands.

Other keys are software keys by design

Only the CA key is in the TPM. The delegated OCSP responder key and the EST server TLS key are generated in software and certified by the CA key. The per-boot management TLS key is generated in software, self-signed, and pinned by fingerprint. The OCSP responder key is stored in etcd at /cryptos/pki/ocsp-responder/key-blob, so OCSP responses are signed without loading the CA key per request.

The node re-mints the responder certificate when it reaches the halfway point of its one-week life, and whenever the stored one isn't signed by the current CA certificate. After a CA key rotation (ca submit-rotation) that happens within 30 seconds, or on the first OCSP request if that comes sooner, so responses are signed by a responder the new key certified.

What keeps the wrapped blob safe: the sealed state volume​

A wrapped key blob plus the TPM it belongs to is enough to sign: the CA key carries no PCR policy of its own. So the second half of the design is keeping the blob where only the right software, on the right machine, can read it.

The state partition (GPT name cryptos-state) is LUKS2-encrypted, and on first boot tpmProtector.ProvisionKey generates a random volume key and seals it with SealToPCR:

  • The sealed object is a keyed-hash object under the SRK with fixedTPM and fixedParent, and without userWithAuth: the only way to unseal it is to satisfy its policy.
  • The policy is TPM2_PolicyPCR over the SHA-256 bank of PCR 7 (the Secure Boot policy) and PCR 11 (systemd-stub's measurement of the UKI sections: kernel, initrd, command line and the rest). PCRs 0, 2 and 4 are left out on purpose so a firmware update doesn't lock the node out.
  • The sealed blobs are stored in the LUKS2 header as a CryptOS-native token of type cryptos-tpm2.

On every later boot UnsealWithPCR loads the sealed object and satisfies the policy with the live PCR values. If the booted image or the Secure Boot state differs from the one the key was sealed under, the TPM refuses, the boot fails with an error containing unseal (PCR drift requires reinit), and PID 1 reboots. There is no shell to drop into.

So an attacker who copies the disk gets an encrypted volume and a sealed blob that only this TPM will open, and only while this image is running. Booting other software on the same machine changes PCR 11, and the TPM won't unseal. See Secure Boot for PCR 7.

In-place upgrades keep this working. Before a new image is written to the ESP, the node predicts the PCR 11 value the new UKI will measure (ukipcr.Predict, which replays systemd-stub's section order), checks the prediction against the running image and the live PCR 11 first, and seals a copy of the same volume key for each bootable image. A prediction it can't trust refuses the stage before anything is written.

No way out: export and reset​

ExportCAKey (cryptosctl ca export-key) refuses on a tpm node with grpc: CA key is non-exportable (TPM-backed). On the software modes it seals the key and chain with Argon2id and AES-256-GCM under a passphrase of at least 18 bytes.

A reset destroys a TPM-held CA key for good

cryptosctl reset (confirmed with --confirm and the CA common name) erases the LUKS header of the state partition. The volume key is gone, so the etcd data holding the wrapped key blob can never be decrypted, and the blob could only have been loaded into this TPM anyway. A tpm node has no export and no escrow: after a reset, its CA key and everything it could sign are unrecoverable. Before you reset a CA, make sure nothing still depends on it.

Where it falls short​

Design goalWhat this release does
Private keys are TPM-sealed and never live on the filesystem in the clear.True for the CA key in tpm mode, ECDSA P-384 or RSA. Every key in nodeid and kms mode is a software key stored inside the encrypted volume.
RSA CA keys in the TPM.Built, for RSA-3072 and RSA-4096 on a TPM that implements the size. Many TPM 2.0 parts implement only RSA-2048, which is below the RSA-3072 floor, and on those the node refuses rather than fall back.
The CA key's use is bound to the booted image.Indirectly. The key object has no PCR policy and an empty auth value; the binding comes from the wrapped blob living only inside the PCR-sealed volume. A PolicyPCR on the key object itself is not in the code.
The seal uses TPM2_PolicyAuthorize, so a signed new image doesn't need a reseal.The code uses a plain TPM2_PolicyPCR. Every new image needs a reseal, which the upgrade path does.
A hardware presence check before a Root key unseals.Not built.
HA pairs share one CA key with TPM2_Duplicate.Not built. The key is created with fixedTPM and fixedParent, so it can't be duplicated as it stands.
Extensions are measured into PCR 14, and the seal binds to it.Not built. ExtendPCR exists in internal/tpm, but nothing calls it and the seal set is PCR 7 and 11.
The CA key signs only certificates, CRLs and OCSP material built by the policy path.The CA key also answers Attest, the FleetOS Manager enrollment challenge. The node never signs the bare nonce: it signs the SHA-384 of CryptOS-PKI attestation v1, a zero byte, the nonce length as a 4-byte big-endian integer, and the nonce. That message can't parse as a TBSCertificate, TBSCertList or OCSP ResponseData, so an Attest signature can't be passed off as a certificate, CRL or OCSP signature. The audit entry records the attestation context, the nonce length and the nonce's SHA-256. The key still does both jobs; there is no separate attestation key.

Where this lives in the code​

All paths are in CryptOS-PKI/cryptos-node at the v0.1.0-rc.1 tag.

PieceCode
Mode selection and fail-fastinternal/init/run.go (newStateKeyBackends, StateKeyMode)
SRKinternal/tpm/srk.go (ProvisionSRK, SRKPersistentHandle)
Key template and creationinternal/tpm/key.go (CreateKey, LoadKey, publicTemplate)
TPM signerinternal/tpm/signer.go (Key.Sign)
Capability probeinternal/tpm/capabilities.go (Probe)
Seal and unsealinternal/tpm/seal.go (SealToPCR, UnsealWithPCR, DefaultSealPCRs), internal/init/statekey.go (tpmProtector)
LUKS2 tokeninternal/storage/luks/token.go (TPM2TokenType)
Upgrade resealinternal/init/statekeyreseal.go, internal/ukipcr/ukipcr.go
Software backendinternal/init/softroot.go, internal/init/nodeid.go, internal/init/kms_protector.go
Where the blobs are storedinternal/node/store.go (CommitFirstCeremony, RootKeyBlobs), internal/storage/etcd/etcd.go
Export and resetinternal/init/escrow.go, internal/backup/backup.go, internal/reset/reset.go
Nonce signinginternal/init/attest.go, internal/grpc/attest.go