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= 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`) +}