Skip to content

tpm: certify a persisted key against the caller's qualifying data - #1092

Open
tedmalone wants to merge 2 commits into
masterfrom
ted/tpm-recertify
Open

tpm: certify a persisted key against the caller's qualifying data#1092
tedmalone wants to merge 2 commits into
masterfrom
ted/tpm-recertify

Conversation

@tedmalone

@tedmalone tedmalone commented Aug 3, 2026

Copy link
Copy Markdown

Unblocked. Pins github.com/smallstep/go-attestation@97f0ab48a939 — the re-certification change (Key.Recertify + unexported key.handle()) rebased onto the previously-pinned e1a87a0 (go-attestation branch ted/recertify-rebased-onto-e1a87a0; 3 files / +42 vs the 28-file go-attestation#11, which was cut from a stale pre-fork master). Builds clean and the simulator tests pass locally (see Testing). Merge still gates on the ted-flex hardware validation tracked in PRO-506.

Why

CreateAttestation returns the certification recorded when the key was created: key.CertificationParameters loads the stored blob (tpm/key.go), and the request URI's qualifying-data is never consulted (kms/tpmkms/tpmkms.go:1653). A caller that supplies qualifying data per request therefore gets the first request's nonce back every time.

ACME device-attest-01 is such a caller — the expected nonce is derived from each order's keyAuthorization. Only the order that created the key can be satisfied by the stored statement, so every subsequent order requires a new key. Consumers end up rotating a hardware credential on every issuance, which silently invalidates anything registered against its public key. This is the root cause behind smallstep/agent's PRO-506.

What

  • tpm.Key.Recertify(ctx, qualifyingData) — loads the key and the AK that attested it, runs a fresh TPM2_Certify against the supplied nonce, and returns the resulting parameters. The key is untouched; only a new signed statement is produced.
  • tpmkms.CreateAttestation calls it when the request URI carries qualifying-data.

With no qualifying data there is nothing to bind, so the stored statement is returned unchanged and existing callers see no behavior change.

Why this is safe for relying parties

The statement Recertify produces is the same TPMS_ATTEST / TagAttestCertify shape as the stored one, over the same key Name and the same key attributes, signed by the same AK. Only ExtraData differs. Verifiers that check the certification (including CertificationParameters.Verify) enforce nothing that binds a statement to key-creation time — confirmed against the CA verifier in smallstep/certificates (acme/challenge.go) and the pinned smallstep/go-attestation fork.

Testing

tpm/recertify_simulator_test.go (-tags tpmsimulator) asserts, against the simulator:

  • the stored certification carries the creation nonce, and re-certifying yields a statement carrying the new nonce;
  • the fresh statement verifies against the AK, and the key's Public bytes are unchanged — same key, new statement;
  • re-certifying does not disturb the stored certification, and the key still signs;
  • a key with no AK reports that it was not attested rather than failing obscurely.

Confirmed passing (Go 1.26 + CGO):

--- PASS: TestKey_Recertify (0.10s)
--- PASS: TestKey_Recertify_notAttested (0.05s)

go build ./tpm/... ./kms/tpmkms/... is clean against the go-attestation pin. Real-hardware validation on ted-flex (restart twice; fingerprint holds while the cert still re-issues) is the remaining check.

CreateAttestation has only ever returned the certification recorded when a
key was created — key.CertificationParameters loads the stored blob — so
the qualifying data in its response is whatever AttestKey was given, no
matter what the request URI carries. A caller that supplies qualifying data
per request gets the first request's nonce back every time.

ACME device-attest-01 is such a caller: the expected nonce comes from each
order's keyAuthorization. Only the order that created the key can be
satisfied by the stored statement, so every subsequent order needs a new
key, and the credential rotates on every issuance.

Add Key.Recertify, which loads the key and the AK that attested it and runs
a fresh TPM2_Certify against a nonce supplied now, and have
CreateAttestation use it whenever the request URI carries qualifying data.
With no qualifying data there is nothing to bind and the stored statement
is returned unchanged, so existing callers are unaffected.

Requires the Key.Recertify added in smallstep/go-attestation#11.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@tedmalone

Copy link
Copy Markdown
Author

Reviewed (PRO-506 batch). Clean and correct. Key.Recertify opens with the key's machineKey scope, loads the attesting AK (GetAK(k.attestedBy)) + key, and calls go-attestation's Recertify; CreateAttestation gates on len(qualifyingData) > 0 with the stored path preserved for back-compat. Signatures match the go-attestation change.

Two notes:

  • Won't compile until the go-attestation dep is bumped to a commit carrying Key.Recertify. Recommend pinning smallstep/go-attestation@97f0ab4 (the recertify change rebased onto the currently-pinned e1a87a0 — 3 files) rather than a master merge of go-attestation#11, which jumps onto a divergent lineage.
  • The recertify_simulator_test.go cases are written but unrun (no CGO on the author's machine) — please confirm they pass in CI before merge; they are the only automated coverage of the re-certify path.

@tedmalone

Copy link
Copy Markdown
Author

Validated against a real TPM and the production CA

The simulator tests in this PR are still unrun (no CGO on the machine this was written on), but the change has now been exercised end to end on hardware — a Windows box with a discrete TPM, running the agent against the production device-attest-01 endpoint, with this branch and smallstep/go-attestation#11 wired in via replace.

Two service restarts, with the agent side changed to use a stable key name (smallstep/agent#1149) so the same persisted key is presented to consecutive ACME orders:

BEFORE [git-signing] key=SHA256:rPs2vP91Y5F+5RxAsp6dhbzAey9I4ldId75ZtchROM4 serial=13964865494044397616
AFTER  [git-signing] key=SHA256:rPs2vP91Y5F+5RxAsp6dhbzAey9I4ldId75ZtchROM4 serial=4179608852556998872

BEFORE [vm-access]   key=SHA256:/MLHwV8XnVvztcIY+sWQ4bfBsY8s+dLVWa5CwoPk4Zo serial=5334878493376260355
AFTER  [vm-access]   key=SHA256:/MLHwV8XnVvztcIY+sWQ4bfBsY8s+dLVWa5CwoPk4Zo serial=12017807660310794754

Same key material, new certificates. Each of those issuances is an ACME order whose keyAuthorization — and therefore whose expected qualifying data — was different from the one the key was created with. Before this change the second order could only have been satisfied by creating a new key.

So the Recertify path is confirmed on a real TPM 2.0 through the Windows PCP provider, and the CA's verifier accepts the resulting statement. Please still run the simulator tests in CI: the hardware run covers the Windows path, not the wrapped/Linux one.

Pins github.com/smallstep/go-attestation to the re-certification change
(Key.Recertify + key.handle()) rebased onto the previously-pinned e1a87a0,
so tpm.Key.Recertify / tpmkms.CreateAttestation compile and the branch is no
longer blocked. Verified: `go build ./tpm/... ./kms/tpmkms/...` passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tedmalone

Copy link
Copy Markdown
Author

Update: bumped github.com/smallstep/go-attestation to 97f0ab48a939 (the re-certify change rebased onto the previously-pinned e1a87a0) and verified locally with Go 1.26 + CGO:

  • go build ./tpm/... ./kms/tpmkms/... — compiles against the new pin.
  • go test -tags tpmsimulator ./tpm/ -run Recertifyboth cases pass:
    --- PASS: TestKey_Recertify (0.10s)
    --- PASS: TestKey_Recertify_notAttested (0.05s)
    

So the re-certify path works end-to-end against the software TPM simulator through the crypto → go-attestation chain. Remaining validation is real-hardware on ted-flex (restart twice; fingerprint holds while the cert still re-issues).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants