From bea650a35f6dde69231254928c92ea8fab713bbd Mon Sep 17 00:00:00 2001 From: Ted Malone Date: Mon, 3 Aug 2026 10:07:59 -0700 Subject: [PATCH 1/4] 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 6c91657556f8f248dc6d35af2a3315dc8976e8a2 Mon Sep 17 00:00:00 2001 From: Ted Malone Date: Mon, 3 Aug 2026 12:26:39 -0700 Subject: [PATCH 2/4] 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 a081dca7..9d1d275b 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 c4491a63..84fcbddb 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= From e683071f03c07d47d0d74e9ceb8cc76fd25a94a2 Mon Sep 17 00:00:00 2001 From: Ted Malone Date: Tue, 11 Aug 2026 07:17:52 -0700 Subject: [PATCH 3/4] tpm: track go-attestation's RecertifyConfig Herman's review on smallstep/go-attestation#12 moved Key.Recertify's nonce into a *attest.RecertifyConfig, for consistency with KeyConfig/AKConfig. Bump the pin to 45ebbb7 and pass the config at the call site. This package's own Key.Recertify signature is unchanged: it sits next to a config-less CertificationParameters(ctx), and the consistency argument was specific to go-attestation. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- tpm/key.go | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 9d1d275b..89934940 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.20260803172805-97f0ab48a939 + github.com/smallstep/go-attestation v0.4.4-0.20260811141324-45ebbb7a46e6 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 84fcbddb..457d7ee8 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.20260803172805-97f0ab48a939 h1:wRNnWGUVQpyYnLr4s9Q2O+3l3sVeocCMuqYcPdZweGQ= -github.com/smallstep/go-attestation v0.4.4-0.20260803172805-97f0ab48a939/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= +github.com/smallstep/go-attestation v0.4.4-0.20260811141324-45ebbb7a46e6 h1:ZHd5/kiw44NNCOxcqEGzYaEkNKuoIifga20eb6s+1wg= +github.com/smallstep/go-attestation v0.4.4-0.20260811141324-45ebbb7a46e6/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= diff --git a/tpm/key.go b/tpm/key.go index f3cd06dd..5edc81aa 100644 --- a/tpm/key.go +++ b/tpm/key.go @@ -528,7 +528,9 @@ func (k *Key) Recertify(ctx context.Context, qualifyingData []byte) (params atte } defer loadedKey.Close() - p, err := loadedKey.Recertify(loadedAK, qualifyingData) + p, err := loadedKey.Recertify(loadedAK, &attest.RecertifyConfig{ + QualifyingData: qualifyingData, + }) if err != nil { return params, fmt.Errorf("failed recertifying key %q: %w", k.name, err) } From 0a1cc848fab31342f52567971dcc11603239f7aa Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Mon, 17 Aug 2026 11:24:03 +0200 Subject: [PATCH 4/4] Use `AK.Recertify` to recertify a `Key` --- go.mod | 2 +- go.sum | 4 +- kms/tpmkms/tpmkms.go | 10 +-- tpm/key.go | 59 +++++++++------- tpm/recertify_simulator_test.go | 118 -------------------------------- tpm/tpm_simulator_test.go | 114 +++++++++++++++++++++++++++++- 6 files changed, 152 insertions(+), 155 deletions(-) delete mode 100644 tpm/recertify_simulator_test.go diff --git a/go.mod b/go.mod index 89934940..d82ea4aa 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.20260811141324-45ebbb7a46e6 + github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 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 457d7ee8..be1c3129 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.20260811141324-45ebbb7a46e6 h1:ZHd5/kiw44NNCOxcqEGzYaEkNKuoIifga20eb6s+1wg= -github.com/smallstep/go-attestation v0.4.4-0.20260811141324-45ebbb7a46e6/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= +github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 h1:KcK2guFXrE5sX/nvF1b+atHP6DjRB1gCv3ppyTXB2Zk= +github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68/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= diff --git a/kms/tpmkms/tpmkms.go b/kms/tpmkms/tpmkms.go index 1c566a39..8b8be7bb 100644 --- a/kms/tpmkms/tpmkms.go +++ b/kms/tpmkms/tpmkms.go @@ -1653,18 +1653,14 @@ func (k *TPMKMS) CreateAttestation(req *apiv1.CreateAttestationRequest) (*apiv1. } // 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. + // it rather than returning the statement recorded at creation. Re-certifying + // lets one persisted key answer every subsequent certification attempt. // // 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) + params, err = key.Recertify(ctx, tpm.RecertifyConfig{QualifyingData: properties.qualifyingData}) if err != nil { return nil, fmt.Errorf("failed recertifying key %q: %w", key.Name(), err) } diff --git a/tpm/key.go b/tpm/key.go index 5edc81aa..65e9f9ba 100644 --- a/tpm/key.go +++ b/tpm/key.go @@ -483,32 +483,38 @@ func (k *Key) CertificationParameters(ctx context.Context) (params attest.Certif return } +// RecertifyConfig encapsulates parameters used for re-certifying keys. +type RecertifyConfig struct { + QualifyingData []byte +} + // 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, binding qualifyingData to the key 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) { +// The key must have been attested by an AK, and it is only supported on TPM 2.0 +// devices. +func (k *Key) Recertify(ctx context.Context, config RecertifyConfig) (params attest.CertificationParameters, err error) { if !k.WasAttested() { return params, fmt.Errorf("key %q was not attested", k.name) } + if len(config.QualifyingData) == 0 { + return params, errors.New("qualifying data required") + } + 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) + key, err := k.tpm.attestTPM.LoadKey(k.data) + if err != nil { + return params, fmt.Errorf("failed loading key %q: %w", k.name, err) + } + defer key.Close() + + storedAK, 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) @@ -516,26 +522,27 @@ func (k *Key) Recertify(ctx context.Context, qualifyingData []byte) (params atte return params, fmt.Errorf("failed getting AK %q: %w", k.attestedBy, err) } - loadedAK, err := k.tpm.attestTPM.LoadAK(ak.Data) + ak, err := k.tpm.attestTPM.LoadAK(storedAK.Data) if err != nil { return params, fmt.Errorf("failed loading AK %q: %w", k.attestedBy, err) } - defer loadedAK.Close(k.tpm.attestTPM) + defer ak.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, &attest.RecertifyConfig{ - QualifyingData: qualifyingData, + p, err := ak.Recertify(k.tpm.attestTPM, key, &attest.RecertifyConfig{ + QualifyingData: config.QualifyingData, }) if err != nil { return params, fmt.Errorf("failed recertifying key %q: %w", k.name, err) } - return *p, nil + // NOTE: new certification parameters are not persisted. This means + // recertification doesn't change the representation of the current + // key, so calling [Key.CertificationParameters] will always return + // the data from creation time. + + params = *p + + return } // Blobs returns a container for the private and public key blobs. diff --git a/tpm/recertify_simulator_test.go b/tpm/recertify_simulator_test.go deleted file mode 100644 index b55d9757..00000000 --- a/tpm/recertify_simulator_test.go +++ /dev/null @@ -1,118 +0,0 @@ -//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`) -} diff --git a/tpm/tpm_simulator_test.go b/tpm/tpm_simulator_test.go index 9e1ce8f2..6a87023c 100644 --- a/tpm/tpm_simulator_test.go +++ b/tpm/tpm_simulator_test.go @@ -21,10 +21,12 @@ import ( "strings" "testing" - "github.com/smallstep/go-attestation/attest" + "github.com/google/go-tpm/legacy/tpm2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/smallstep/go-attestation/attest" + "go.step.sm/crypto/keyutil" "go.step.sm/crypto/minica" "go.step.sm/crypto/tpm/algorithm" @@ -1168,3 +1170,113 @@ func TestTPMOnlyFailsWithoutStorageWhenRequired(t *testing.T) { _, err = tpm.GetAKByPermanentIdentifier(ctx, "permanent-identifier") require.ErrorIs(t, err, ErrNoStorageConfigured) } + +// 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 qualifying data chosen after +// the key was created. Without it, binding new qualifying data 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) + + firstQualifyingData := []byte("first-qualifying-data") + key, err := tpm.AttestKey(ctx, "ak", "key", AttestKeyConfig{ + Algorithm: "RSA", + Size: 2048, + QualifyingData: firstQualifyingData, + }) + require.NoError(t, err) + + verifyOpts := akVerifyOpts(t, ak) + + // The stored certification carries the qualifying data the key + // was created with. + stored, err := key.CertificationParameters(ctx) + require.NoError(t, err) + require.NoError(t, stored.Verify(verifyOpts)) + assert.Equal(t, firstQualifyingData, extraData(t, stored)) + + // Re-certifying binds different qualifying data to the same key. + secondQualifyingData := []byte("second-qualifying-data") + fresh, err := key.Recertify(ctx, RecertifyConfig{QualifyingData: secondQualifyingData}) + require.NoError(t, err) + + assert.Equal(t, secondQualifyingData, 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, firstQualifyingData, 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) + + // Empty qualifying data is not supported, and returns an error + empty, err := key.Recertify(ctx, RecertifyConfig{QualifyingData: nil}) + assert.Error(t, err) + assert.Empty(t, empty) +} + +// 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, RecertifyConfig{QualifyingData: []byte("qualifying-data")}) + assert.EqualError(t, err, `key "unattested" was not attested`) +}