From 09ffe696b797df24ba66913cfa98b461cb6c2c82 Mon Sep 17 00:00:00 2001 From: Ted Malone Date: Mon, 3 Aug 2026 10:07:59 -0700 Subject: [PATCH 1/2] tpm: certify a persisted key against the caller's qualifying data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- kms/tpmkms/tpmkms.go | 21 +++++- tpm/key.go | 58 ++++++++++++++++ tpm/recertify_simulator_test.go | 118 ++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 tpm/recertify_simulator_test.go diff --git a/kms/tpmkms/tpmkms.go b/kms/tpmkms/tpmkms.go index 3d68e402..1c566a39 100644 --- a/kms/tpmkms/tpmkms.go +++ b/kms/tpmkms/tpmkms.go @@ -24,6 +24,8 @@ import ( "strings" "time" + "github.com/smallstep/go-attestation/attest" + "go.step.sm/crypto/kms/apiv1" "go.step.sm/crypto/kms/uri" "go.step.sm/crypto/tpm" @@ -1650,8 +1652,23 @@ func (k *TPMKMS) CreateAttestation(req *apiv1.CreateAttestationRequest) (*apiv1. return nil, fmt.Errorf("failed getting signer for key %q: %w", properties.name, err) } - params, err := key.CertificationParameters(ctx) - if err != nil { + // When the request carries qualifying data, certify the key again against + // it rather than returning the statement recorded at creation. A caller + // that supplies a nonce is proving possession to something that chose that + // nonce — an ACME device-attest-01 challenge, say — and the stored + // statement carries whatever nonce the key was created with, so it only + // ever satisfies the first such challenge. Re-certifying lets one + // persisted key answer every subsequent one. + // + // With no qualifying data there is nothing to bind, so the stored + // statement is returned unchanged and existing callers are unaffected. + var params attest.CertificationParameters + if len(properties.qualifyingData) > 0 { + params, err = key.Recertify(ctx, properties.qualifyingData) + if err != nil { + return nil, fmt.Errorf("failed recertifying key %q: %w", key.Name(), err) + } + } else if params, err = key.CertificationParameters(ctx); err != nil { return nil, fmt.Errorf("failed getting key certification parameters for %q: %w", key.Name(), err) } diff --git a/tpm/key.go b/tpm/key.go index a112e8bc..f3cd06dd 100644 --- a/tpm/key.go +++ b/tpm/key.go @@ -461,6 +461,11 @@ func (k *Key) Signer(ctx context.Context) (crypto.Signer, error) { // CertificationParameters returns information about the key that can be used to // verify key certification. +// +// The parameters are the ones recorded when the key was created: the +// TPM2_Certify [TPM.AttestKey] performs, with that call's +// [AttestKeyConfig.QualifyingData] frozen in as the nonce. Use +// [Key.Recertify] to obtain parameters over a different nonce. func (k *Key) CertificationParameters(ctx context.Context) (params attest.CertificationParameters, err error) { if err = k.tpm.open(ctx, openOptions{machineKey: k.machineKey}); err != nil { return params, fmt.Errorf("failed opening TPM: %w", err) @@ -478,6 +483,59 @@ func (k *Key) CertificationParameters(ctx context.Context) (params attest.Certif return } +// Recertify runs a fresh TPM2_Certify over the key using the AK that attested +// it, binding qualifyingData as the nonce, and returns the resulting +// parameters. The key itself is untouched: only a new signed statement about +// it is produced. +// +// It exists for protocols that bind a per-transaction challenge into the +// certification. ACME device-attest-01 derives the expected nonce from the +// order's keyAuthorization, so proving possession for a second order against +// the parameters from [Key.CertificationParameters] fails — those carry the +// first order's nonce. Without re-certification the only way to satisfy a new +// order is a new key, which rotates the credential and invalidates anything +// registered against its public key. +// +// The key must have been attested by an AK ([Key.WasAttested]), and the TPM +// must be a 2.0 device. +func (k *Key) Recertify(ctx context.Context, qualifyingData []byte) (params attest.CertificationParameters, err error) { + if !k.WasAttested() { + return params, fmt.Errorf("key %q was not attested", k.name) + } + + if err = k.tpm.open(ctx, openOptions{machineKey: k.machineKey}); err != nil { + return params, fmt.Errorf("failed opening TPM: %w", err) + } + defer closeTPM(ctx, k.tpm, &err) + + ak, err := k.tpm.store.GetAK(k.attestedBy) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return params, fmt.Errorf("failed getting AK %q: %w", k.attestedBy, ErrNotFound) + } + return params, fmt.Errorf("failed getting AK %q: %w", k.attestedBy, err) + } + + loadedAK, err := k.tpm.attestTPM.LoadAK(ak.Data) + if err != nil { + return params, fmt.Errorf("failed loading AK %q: %w", k.attestedBy, err) + } + defer loadedAK.Close(k.tpm.attestTPM) + + loadedKey, err := k.tpm.attestTPM.LoadKey(k.data) + if err != nil { + return params, fmt.Errorf("failed loading key %q: %w", k.name, err) + } + defer loadedKey.Close() + + p, err := loadedKey.Recertify(loadedAK, qualifyingData) + if err != nil { + return params, fmt.Errorf("failed recertifying key %q: %w", k.name, err) + } + + return *p, nil +} + // Blobs returns a container for the private and public key blobs. // The resulting blobs are compatible with tpm2-tools, so can be used // like this (after having been written to key.priv and key.pub): diff --git a/tpm/recertify_simulator_test.go b/tpm/recertify_simulator_test.go new file mode 100644 index 00000000..b55d9757 --- /dev/null +++ b/tpm/recertify_simulator_test.go @@ -0,0 +1,118 @@ +//go:build tpmsimulator + +package tpm + +import ( + "context" + "crypto" + "crypto/rsa" + "testing" + + "github.com/google/go-tpm/legacy/tpm2" + "github.com/smallstep/go-attestation/attest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// akVerifyOpts builds the options needed to verify a certification signed by +// ak, mirroring what a relying party does with the AK's public key. +func akVerifyOpts(t *testing.T, ak *AK) attest.VerifyOpts { + t.Helper() + + params, err := ak.AttestationParameters(context.Background()) + require.NoError(t, err) + + pub, err := tpm2.DecodePublic(params.Public) + require.NoError(t, err) + + hash, err := pub.RSAParameters.Sign.Hash.Hash() + require.NoError(t, err) + + return attest.VerifyOpts{ + Public: &rsa.PublicKey{ + E: int(pub.RSAParameters.Exponent()), + N: pub.RSAParameters.Modulus(), + }, + Hash: hash, + } +} + +// extraData returns the qualifying data bound into a certification. +func extraData(t *testing.T, params attest.CertificationParameters) []byte { + t.Helper() + + att, err := tpm2.DecodeAttestationData(params.CreateAttestation) + require.NoError(t, err) + require.Equal(t, tpm2.TagAttestCertify, att.Type) + + return att.ExtraData +} + +// TestKey_Recertify covers the property the change exists for: one persisted +// key can produce a valid certification against a nonce chosen after the key +// was created. Without it, binding a new nonce requires a new key. +func TestKey_Recertify(t *testing.T) { + ctx := context.Background() + tpm := newSimulatedTPM(t) + + ak, err := tpm.CreateAK(ctx, "ak") + require.NoError(t, err) + + firstNonce := []byte("first-order-key-authorization") + key, err := tpm.AttestKey(ctx, "ak", "key", AttestKeyConfig{ + Algorithm: "RSA", + Size: 2048, + QualifyingData: firstNonce, + }) + require.NoError(t, err) + + verifyOpts := akVerifyOpts(t, ak) + + // The stored certification carries the nonce the key was created with. + stored, err := key.CertificationParameters(ctx) + require.NoError(t, err) + require.NoError(t, stored.Verify(verifyOpts)) + assert.Equal(t, firstNonce, extraData(t, stored)) + + // Re-certifying binds a different nonce to the same key. + secondNonce := []byte("second-order-key-authorization") + fresh, err := key.Recertify(ctx, secondNonce) + require.NoError(t, err) + + assert.Equal(t, secondNonce, extraData(t, fresh)) + assert.NotEqual(t, stored.CreateAttestation, fresh.CreateAttestation) + assert.NotEqual(t, stored.CreateSignature, fresh.CreateSignature) + + // It is still the same key, and the fresh statement satisfies every check + // a relying party makes — this is what lets the credential persist. + assert.Equal(t, stored.Public, fresh.Public) + require.NoError(t, fresh.Verify(verifyOpts)) + + // Re-certifying does not disturb the stored certification. + reread, err := key.CertificationParameters(ctx) + require.NoError(t, err) + assert.Equal(t, firstNonce, extraData(t, reread)) + + // The key remains usable for signing. + signer, err := key.Signer(ctx) + require.NoError(t, err) + digest := []byte("01234567890123456789012345678901") + _, err = signer.Sign(nil, digest, crypto.SHA256) + require.NoError(t, err) +} + +// TestKey_Recertify_notAttested guards the precondition: a key with no AK has +// nothing to certify it, and must say so rather than fail obscurely. +func TestKey_Recertify_notAttested(t *testing.T) { + ctx := context.Background() + tpm := newSimulatedTPM(t) + + key, err := tpm.CreateKey(ctx, "unattested", CreateKeyConfig{ + Algorithm: "RSA", + Size: 2048, + }) + require.NoError(t, err) + + _, err = key.Recertify(ctx, []byte("nonce")) + assert.EqualError(t, err, `key "unattested" was not attested`) +} From 0a5007edf0fa7765227e345a2770eb293b86715f Mon Sep 17 00:00:00 2001 From: Ted Malone Date: Mon, 3 Aug 2026 12:26:39 -0700 Subject: [PATCH 2/2] go.mod: bump go-attestation to 97f0ab48a939 for Key.Recertify 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) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 50cd9d9c..52aacd6f 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/peterbourgon/diskv/v3 v3.0.1 github.com/pkg/errors v0.9.1 github.com/schollz/jsonstore v1.1.0 - github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 + github.com/smallstep/go-attestation v0.4.4-0.20260803172805-97f0ab48a939 github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 golang.org/x/crypto v0.54.0 diff --git a/go.sum b/go.sum index e1c65ec3..ae448700 100644 --- a/go.sum +++ b/go.sum @@ -764,8 +764,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 h1:n+X1wnMKJMcCRd98YKAo/56tMRSPUg+qjAvNNS1EZeM= -github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= +github.com/smallstep/go-attestation v0.4.4-0.20260803172805-97f0ab48a939 h1:wRNnWGUVQpyYnLr4s9Q2O+3l3sVeocCMuqYcPdZweGQ= +github.com/smallstep/go-attestation v0.4.4-0.20260803172805-97f0ab48a939/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=