diff --git a/go.mod b/go.mod index a081dca7..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.20260603212853-e1a87a0b07d9 + 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 c4491a63..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.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.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 3d68e402..8b8be7bb 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,19 @@ 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. 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, tpm.RecertifyConfig{QualifyingData: 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..65e9f9ba 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,68 @@ 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 to the key and returns the resulting parameters. +// The key itself is untouched: only a new signed statement about it is produced. +// +// 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) + + 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) + } + return params, fmt.Errorf("failed getting AK %q: %w", k.attestedBy, err) + } + + ak, err := k.tpm.attestTPM.LoadAK(storedAK.Data) + if err != nil { + return params, fmt.Errorf("failed loading AK %q: %w", k.attestedBy, err) + } + defer ak.Close(k.tpm.attestTPM) + + 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) + } + + // 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. // 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/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`) +}