diff --git a/go.mod b/go.mod index cc338f10b1e79..90363919b4658 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,8 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/go-containerregistry v0.17.0 github.com/google/go-querystring v1.1.0 - github.com/google/go-tpm-tools v0.4.2 + github.com/google/go-tpm v0.9.0 + github.com/google/go-tpm-tools v0.4.4 github.com/google/renameio/v2 v2.0.0 github.com/google/safetext v0.0.0-20240104143208-7a7d9b3d812f github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -351,7 +352,7 @@ require ( github.com/google/certificate-transparency-go v1.1.7 // indirect github.com/google/flatbuffers v23.1.21+incompatible // indirect github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect - github.com/google/go-tpm v0.9.0 // indirect + github.com/google/go-configfs-tsm v0.2.2 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.7 // indirect diff --git a/go.sum b/go.sum index 17bb7d431b274..cef5a1a81dd85 100644 --- a/go.sum +++ b/go.sum @@ -731,6 +731,8 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-configfs-tsm v0.2.2 h1:YnJ9rXIOj5BYD7/0DNnzs8AOp7UcvjfTvt215EWcs98= +github.com/google/go-configfs-tsm v0.2.2/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= github.com/google/go-containerregistry v0.17.0 h1:5p+zYs/R4VGHkhyvgWurWrpJ2hW4Vv9fQI+GzdcwXLk= github.com/google/go-containerregistry v0.17.0/go.mod h1:u0qB2l7mvtWVR5kNcbFIhFY1hLbf8eeGapA+vbFDCtQ= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= @@ -740,12 +742,12 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-sev-guest v0.9.3 h1:GOJ+EipURdeWFl/YYdgcCxyPeMgQUWlI056iFkBD8UU= github.com/google/go-sev-guest v0.9.3/go.mod h1:hc1R4R6f8+NcJwITs0L90fYWTsBpd1Ix+Gur15sqHDs= -github.com/google/go-tdx-guest v0.2.3-0.20231011100059-4cf02bed9d33 h1:lRlUusuieEuqljjihCXb+Mr73VNitOYPJYWXzJKtBWs= -github.com/google/go-tdx-guest v0.2.3-0.20231011100059-4cf02bed9d33/go.mod h1:84ut3oago/BqPXD4ppiGXdkZNW3WFPkcyAO4my2hXdY= +github.com/google/go-tdx-guest v0.3.1 h1:gl0KvjdsD4RrJzyLefDOvFOUH3NAJri/3qvaL5m83Iw= +github.com/google/go-tdx-guest v0.3.1/go.mod h1:/rc3d7rnPykOPuY8U9saMyEps0PZDThLk/RygXm04nE= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= -github.com/google/go-tpm-tools v0.4.2 h1:iyaCPKt2N5Rd0yz0G8ANa022SgCNZkMpp+db6QELtvI= -github.com/google/go-tpm-tools v0.4.2/go.mod h1:fGUDZu4tw3V4hUVuFHmiYgRd0c58/IXivn9v3Ea/ck4= +github.com/google/go-tpm-tools v0.4.4 h1:oiQfAIkc6xTy9Fl5NKTeTJkBTlXdHsxAofmQyxBKY98= +github.com/google/go-tpm-tools v0.4.4/go.mod h1:T8jXkp2s+eltnCDIsXR84/MTcVU9Ja7bh3Mit0pa4AY= github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/lib/tpm/proto.go b/lib/tpm/proto.go new file mode 100644 index 0000000000000..d543c24f23316 --- /dev/null +++ b/lib/tpm/proto.go @@ -0,0 +1,74 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tpm + +import ( + "github.com/google/go-attestation/attest" + + "github.com/gravitational/teleport/api/client/proto" +) + +// AttestationParametersToProto converts an attest.AttestationParameters to +// its protobuf representation. +func AttestationParametersToProto(in attest.AttestationParameters) *proto.TPMAttestationParameters { + return &proto.TPMAttestationParameters{ + Public: in.Public, + CreateData: in.CreateData, + CreateAttestation: in.CreateAttestation, + CreateSignature: in.CreateSignature, + } +} + +// AttestationParametersFromProto extracts an attest.AttestationParameters from +// its protobuf representation. +func AttestationParametersFromProto(in *proto.TPMAttestationParameters) attest.AttestationParameters { + if in == nil { + return attest.AttestationParameters{} + } + return attest.AttestationParameters{ + Public: in.Public, + CreateData: in.CreateData, + CreateAttestation: in.CreateAttestation, + CreateSignature: in.CreateSignature, + } +} + +// EncryptedCredentialToProto converts an attest.EncryptedCredential to +// its protobuf representation. +func EncryptedCredentialToProto(in *attest.EncryptedCredential) *proto.TPMEncryptedCredential { + if in == nil { + return nil + } + return &proto.TPMEncryptedCredential{ + CredentialBlob: in.Credential, + Secret: in.Secret, + } +} + +// EncryptedCredentialFromProto extracts an attest.EncryptedCredential from +// its protobuf representation. +func EncryptedCredentialFromProto(in *proto.TPMEncryptedCredential) *attest.EncryptedCredential { + if in == nil { + return nil + } + return &attest.EncryptedCredential{ + Credential: in.CredentialBlob, + Secret: in.Secret, + } +} diff --git a/lib/tpm/proto_test.go b/lib/tpm/proto_test.go new file mode 100644 index 0000000000000..e0a9eca31333f --- /dev/null +++ b/lib/tpm/proto_test.go @@ -0,0 +1,52 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tpm + +import ( + "testing" + + "github.com/google/go-attestation/attest" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils" +) + +func TestAttestationParametersProto(t *testing.T) { + want := attest.AttestationParameters{ + Public: []byte("public"), + CreateData: []byte("create_data"), + CreateAttestation: []byte("create_attestation"), + CreateSignature: []byte("create_signature"), + } + pb := AttestationParametersToProto(want) + clonedPb := utils.CloneProtoMsg(pb) + got := AttestationParametersFromProto(clonedPb) + require.Equal(t, want, got) +} + +func TestEncryptedCredentialProto(t *testing.T) { + want := &attest.EncryptedCredential{ + Credential: []byte("encrypted_credential"), + Secret: []byte("secret"), + } + pb := EncryptedCredentialToProto(want) + clonedPb := utils.CloneProtoMsg(pb) + got := EncryptedCredentialFromProto(clonedPb) + require.Equal(t, want, got) +} diff --git a/lib/tpm/tpm.go b/lib/tpm/tpm.go new file mode 100644 index 0000000000000..e83000535bf53 --- /dev/null +++ b/lib/tpm/tpm.go @@ -0,0 +1,231 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tpm + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "fmt" + "log/slog" + "math/big" + "strings" + + "github.com/google/go-attestation/attest" + "github.com/gravitational/trace" + "go.opentelemetry.io/otel" +) + +var tracer = otel.Tracer("github.com/gravitational/teleport/lib/tpm") + +// serialString converts a serial number into a readable colon-delimited hex +// string thats user-readable e.g ab:ab:ab:ff:ff:ff +func serialString(serial *big.Int) string { + hex := serial.Text(16) + + out := strings.Builder{} + // Handle odd-sized strings. + if len(hex)%2 == 1 { + out.WriteRune('0') + out.WriteRune(rune(hex[0])) + if len(hex) > 1 { + out.WriteRune(':') + } + hex = hex[1:] + } + for i := 0; i < len(hex); i += 2 { + if i != 0 { + out.WriteString(":") + } + out.WriteString(hex[i : i+2]) + } + return out.String() +} + +// hashEKPub hashes the public part of an EK key. The key is hashed with SHA256, +// and returned as a hexadecimal string. +func hashEKPub(pkixPublicKey []byte) (string, error) { + hashed := sha256.Sum256(pkixPublicKey) + return fmt.Sprintf("%x", hashed), nil +} + +// QueryRes is the result of the TPM query performed by Query. +type QueryRes struct { + // EKPub is the PKIX marshaled public part of the EK. + EKPub []byte + // EKPubHash is the SHA256 hash of the PKIX marshaled EKPub in hexadecimal + // format. + EKPubHash string + // EKCert holds the information about the EKCert if present. If nil, the + // TPM does not have an EKCert. + EKCert *QueryEKCert +} + +// QueryEKCert contains the EKCert information if present. +type QueryEKCert struct { + // Raw is the ASN.1 DER encoded EKCert. + Raw []byte + // SerialNumber is the serial number of the EKCert represented as a colon + // delimited hex string. + SerialNumber string +} + +// Query returns information about the TPM on a system, including the +// EKPubHash and EKCertSerial which are needed to configure TPM joining. +func Query(ctx context.Context, log *slog.Logger) (*QueryRes, error) { + ctx, span := tracer.Start(ctx, "Query") + defer span.End() + + tpm, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + }) + if err != nil { + return nil, trace.Wrap(err) + } + defer func() { + if err := tpm.Close(); err != nil { + log.WarnContext( + ctx, + "Failed to close TPM", + slog.String("error", err.Error()), + ) + } + }() + return QueryWithTPM(ctx, log, tpm) +} + +// QueryWithTPM is similar to Query, but accepts an already opened TPM. +func QueryWithTPM( + ctx context.Context, log *slog.Logger, tpm *attest.TPM, +) (*QueryRes, error) { + ctx, span := tracer.Start(ctx, "QueryWithTPM") + defer span.End() + + data := &QueryRes{} + + eks, err := tpm.EKs() + if err != nil { + return nil, trace.Wrap(err, "querying EKs") + } + + // The first EK returned by `go-attestation` will be an RSA based EK key or + // EK cert. On Windows, ECC certs may also be returned following this. At + // this time, we are only interested in RSA certs, so we just consider the + // first thing returned. + ekPub, err := x509.MarshalPKIXPublicKey(eks[0].Public) + if err != nil { + return nil, trace.Wrap(err) + } + data.EKPub = ekPub + data.EKPubHash, err = hashEKPub(ekPub) + if err != nil { + return nil, trace.Wrap(err, "hashing ekpub") + } + + if eks[0].Certificate != nil { + data.EKCert = &QueryEKCert{ + Raw: eks[0].Certificate.Raw, + SerialNumber: serialString(eks[0].Certificate.SerialNumber), + } + } + log.DebugContext(ctx, "Successfully queried TPM", "data", data) + return data, nil +} + +// Attestation holds the information necessary to perform a TPM join to a +// Teleport cluster. +type Attestation struct { + // Data holds the queried information about the EK and EKCert if present. + Data QueryRes + // AttestParams holds the attestation parameters for the AK created for + // this join ceremony. + AttestParams attest.AttestationParameters + // Solve is a function that should be called when the encrypted credential + // challenge is received from the server. + Solve func(ec *attest.EncryptedCredential) ([]byte, error) +} + +// Attest provides the information necessary to perform a TPM join to a Teleport +// cluster. It returns a solve function which should be called when the +// encrypted credential challenge is received from the server. +// The Close function must be called if Attest returns in a non-error state. +func Attest(ctx context.Context, log *slog.Logger) ( + att *Attestation, + close func() error, + err error, +) { + ctx, span := tracer.Start(ctx, "Attest") + defer span.End() + + tpm, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + }) + if err != nil { + return nil, nil, trace.Wrap(err) + } + defer func() { + if err != nil { + if err := tpm.Close(); err != nil { + log.WarnContext( + ctx, + "Failed to close TPM", + slog.String("error", err.Error()), + ) + } + } + }() + + att, err = AttestWithTPM(ctx, log, tpm) + if err != nil { + return nil, nil, trace.Wrap(err, "attesting with TPM") + } + + return att, tpm.Close, nil + +} + +// AttestWithTPM is similar to Attest, but accepts an already opened TPM. +func AttestWithTPM(ctx context.Context, log *slog.Logger, tpm *attest.TPM) ( + att *Attestation, + err error, +) { + ctx, span := tracer.Start(ctx, "AttestWithTPM") + defer span.End() + + queryData, err := QueryWithTPM(ctx, log, tpm) + if err != nil { + return nil, trace.Wrap(err, "querying TPM") + } + + // Create AK and calculate attestation parameters. + ak, err := tpm.NewAK(&attest.AKConfig{}) + if err != nil { + return nil, trace.Wrap(err, "creating ak") + } + log.DebugContext(ctx, "Successfully generated AK for TPM") + + return &Attestation{ + Data: *queryData, + AttestParams: ak.AttestationParameters(), + Solve: func(ec *attest.EncryptedCredential) ([]byte, error) { + log.DebugContext(ctx, "Solving credential challenge") + return ak.ActivateCredential(tpm, *ec) + }, + }, nil +} diff --git a/lib/tpm/tpm_simulator_test.go b/lib/tpm/tpm_simulator_test.go new file mode 100644 index 0000000000000..a71fce746569d --- /dev/null +++ b/lib/tpm/tpm_simulator_test.go @@ -0,0 +1,244 @@ +//go:build tpmsimulator + +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tpm_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io" + "log/slog" + "math/big" + "testing" + "time" + + "github.com/google/go-attestation/attest" + gocmp "github.com/google/go-cmp/cmp" + tpmsimulator "github.com/google/go-tpm-tools/simulator" + "github.com/google/go-tpm/legacy/tpm2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/fixtures" + "github.com/gravitational/teleport/lib/tpm" +) + +// fakeCmdChannel is used to inject the TPM simulator into `go-attestation`'s +// TPM wrapper. +type fakeCmdChannel struct { + io.ReadWriteCloser +} + +// MeasurementLog implements CommandChannelTPM20. +func (cc *fakeCmdChannel) MeasurementLog() ([]byte, error) { + // Nothing to do here - we don't use the measurement log for tpm joining. + return nil, nil +} + +func writeEKCertToTPM(t *testing.T, sim *tpmsimulator.Simulator, data []byte) { + // As per TCG Credential Profile EK for TPM 2.0, 2.2.1.4, the RSA 2048 + // EK certificate is stored in the TPM's NV index 0x1c00002. + const nvramRSAEKCertIndex = 0x1c00002 + err := tpm2.NVDefineSpace( + sim, + tpm2.HandlePlatform, // Using Platform Authorization. + nvramRSAEKCertIndex, + "", // As this is the simulator, there isn't a password for Platform Authorization. + "", // We do not configure a password for this index. This allows it to be read using the NV index as the auth handle. + nil, + tpm2.AttrPPWrite| // Allows this NV index to be written with platform authorization. + tpm2.AttrPPRead| // Allows this NV index to be read with platform authorization. + tpm2.AttrPlatformCreate| // Marks this index as created by the Platform + tpm2.AttrAuthRead, // Allows the nv index to be used as an auth handle to read itself. + + uint16(len(data)), + ) + require.NoError(t, err) + + err = tpm2.NVWrite( + sim, + tpm2.HandlePlatform, + nvramRSAEKCertIndex, + "", + data, + 0, + ) + require.NoError(t, err) +} + +// To include tests based on this simulator, use the `tpmsimulator` build tag. +// This requires openssl libraries to be installed on the machine and findable +// by the compiler. On macOS: +// brew install openssl +// export C_INCLUDE_PATH="$(brew --prefix openssl)/include" +// export LIBRARY_PATH="$(brew --prefix openssl)/lib" +// go test ./lib/tpm -run TestWithSimulator -tags tpmsimulator + +func TestWithSimulator(t *testing.T) { + t.Parallel() + ctx := context.Background() + log := slog.Default() + + ca := &x509.Certificate{ + SerialNumber: big.NewInt(1), + BasicConstraintsValid: true, + IsCA: true, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + caBytes, err := x509.CreateCertificate( + rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey, + ) + require.NoError(t, err) + caPEM := pem.EncodeToMemory( + &pem.Block{Type: "CERTIFICATE", Bytes: caBytes}, + ) + caPool := x509.NewCertPool() + require.True(t, caPool.AppendCertsFromPEM(caPEM)) + wrongCAPool := x509.NewCertPool() + require.True(t, wrongCAPool.AppendCertsFromPEM([]byte(fixtures.TLSCACertPEM))) + + sim, err := tpmsimulator.GetWithFixedSeedInsecure(0) + require.NoError(t, err) + // This is the EKPubHash that results from the EK generated with the seed 0. + const wantEKPubHash = "1b5bbe2e96054f7bc34ebe7ba9a4a9eac5611c6879285ceff6094fa556af485c" + + attestTPM, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + CommandChannel: &fakeCmdChannel{ReadWriteCloser: sim}, + }) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, attestTPM.Close(), "TPM simulator close errored") + }) + + tpmEKs, err := attestTPM.EKs() + require.NoError(t, err) + wantEKPub, err := x509.MarshalPKIXPublicKey(tpmEKs[0].Public) + require.NoError(t, err) + + t.Run("Without EKCert", func(t *testing.T) { + att, err := tpm.AttestWithTPM(ctx, log, attestTPM) + require.NoError(t, err) + + // Check QueryRes looks right. + assert.Empty(t, gocmp.Diff(tpm.QueryRes{ + EKPubHash: wantEKPubHash, + EKPub: wantEKPub, + }, att.Data)) + + t.Run("Success", func(t *testing.T) { + validated, err := tpm.Validate(ctx, log, tpm.ValidateParams{ + EKKey: att.Data.EKPub, + AttestParams: att.AttestParams, + Solve: att.Solve, + }) + require.NoError(t, err) + assert.Empty(t, gocmp.Diff(&tpm.ValidatedTPM{ + EKPubHash: wantEKPubHash, + EKCertVerified: false, + }, validated)) + }) + t.Run("Failure due to missing EKCert", func(t *testing.T) { + _, err = tpm.Validate(ctx, log, tpm.ValidateParams{ + EKKey: att.Data.EKPub, + AttestParams: att.AttestParams, + Solve: att.Solve, + AllowedCAs: caPool, + }) + assert.ErrorContains(t, err, "tpm did not provide an EKCert to validate against allowed CAs") + }) + }) + + // Write fake EKCert to the TPM + const ekCertSerialNum = 1337133713371337 + const ekCertSerialHex = "04:c0:1d:b4:00:b0:c9" + fakeEKCert := &x509.Certificate{ + SerialNumber: big.NewInt(ekCertSerialNum), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + fakeEKBytes, err := x509.CreateCertificate( + rand.Reader, fakeEKCert, ca, tpmEKs[0].Public, caPrivKey, + ) + require.NoError(t, err) + writeEKCertToTPM(t, sim, fakeEKBytes) + + t.Run("With EKCert", func(t *testing.T) { + att, err := tpm.AttestWithTPM(ctx, log, attestTPM) + require.NoError(t, err) + + // Check queryRes looks right. + assert.Empty(t, gocmp.Diff(tpm.QueryRes{ + EKPubHash: wantEKPubHash, + EKPub: wantEKPub, + EKCert: &tpm.QueryEKCert{ + Raw: fakeEKBytes, + SerialNumber: ekCertSerialHex, + }, + }, att.Data)) + + t.Run("Success without CAs", func(t *testing.T) { + validated, err := tpm.Validate(ctx, log, tpm.ValidateParams{ + EKKey: att.Data.EKPub, + EKCert: att.Data.EKCert.Raw, + AttestParams: att.AttestParams, + Solve: att.Solve, + }) + require.NoError(t, err) + assert.Empty(t, gocmp.Diff(&tpm.ValidatedTPM{ + EKPubHash: wantEKPubHash, + EKCertVerified: false, + EKCertSerial: ekCertSerialHex, + }, validated)) + }) + t.Run("Success with CAs", func(t *testing.T) { + validated, err := tpm.Validate(ctx, log, tpm.ValidateParams{ + EKKey: att.Data.EKPub, + EKCert: att.Data.EKCert.Raw, + AttestParams: att.AttestParams, + Solve: att.Solve, + AllowedCAs: caPool, + }) + require.NoError(t, err) + assert.Empty(t, gocmp.Diff(&tpm.ValidatedTPM{ + EKPubHash: wantEKPubHash, + EKCertVerified: true, + EKCertSerial: ekCertSerialHex, + }, validated)) + }) + t.Run("Failure with wrong CA", func(t *testing.T) { + _, err := tpm.Validate(ctx, log, tpm.ValidateParams{ + EKKey: att.Data.EKPub, + EKCert: att.Data.EKCert.Raw, + AttestParams: att.AttestParams, + Solve: att.Solve, + // Some random CA that won't match the EKCert. + AllowedCAs: wrongCAPool, + }) + assert.ErrorContains(t, err, "certificate signed by unknown authority") + }) + }) +} diff --git a/lib/tpm/validate.go b/lib/tpm/validate.go new file mode 100644 index 0000000000000..d0c9b74736342 --- /dev/null +++ b/lib/tpm/validate.go @@ -0,0 +1,201 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tpm + +import ( + "context" + "crypto" + "crypto/subtle" + "crypto/x509" + "log/slog" + + "github.com/google/go-attestation/attest" + "github.com/gravitational/trace" +) + +// ValidateParams are the parameters required to validate a TPM. +type ValidateParams struct { + // EKCert should be the EK certificate in ASN.1 DER format. At least one of + // EKCert or EKKey must be provided. + EKCert []byte + // EKKey should be the public part of the EK key in PKIX format. At least + // one of EKCert or EKKey must be provided. If EKCert is provided, EKKey + // will be ignored. + EKKey []byte + // AttestParams are the parameters required to attest the TPM provided by + // the client. These relate to an AK that has been generated for this + // ceremony. + AttestParams attest.AttestationParameters + // Solve is the function that Validate should call when it has prepared the + // challenge and needs the remote TPM to solve it. + Solve func(ec *attest.EncryptedCredential) ([]byte, error) + // AllowedCAs is a pool of PEM encoded CAs that are allowed to sign the + // EKCert. If this value is nil, the EKCert will not be verified. + AllowedCAs *x509.CertPool +} + +// ValidatedTPM is returned by Validate and contains the validated information +// about the remote TPM. +type ValidatedTPM struct { + // EKPubHash is the SHA256 hash of the PKIX marshaled EKPub in hex format. + EKPubHash string `json:"ek_pub_hash"` + // EKCertSerial is the serial number of the EK cert represented as a colon + // delimited hex string. If there is no EKCert, this field will be empty. + EKCertSerial string `json:"ek_cert_serial,omitempty"` + // EKCertVerified is true if the EKCert was verified against the allowed + // CAs. + EKCertVerified bool `json:"ek_cert_verified"` +} + +// JoinAuditAttributes returns a series of attributes that can be inserted into +// audit events related to a specific join. +func (c *ValidatedTPM) JoinAuditAttributes() (map[string]interface{}, error) { + return map[string]interface{}{ + "ek_pub_hash": c.EKPubHash, + "ek_cert_serial": c.EKCertSerial, + "ek_cert_verified": c.EKCertVerified, + }, nil +} + +// Validate takes the parameters from a remote TPM and performs the necessary +// initial checks before then generating an encrypted credential challenge for +// the client to solve in a credential activation ceremony. This allows us to +// verify that the client possesses the TPM corresponding to the EK public key +// or certificate presented by the client. +func Validate( + ctx context.Context, log *slog.Logger, params ValidateParams, +) (*ValidatedTPM, error) { + ctx, span := tracer.Start(ctx, "Validate") + defer span.End() + + // Validate params + switch { + case params.Solve == nil: + return nil, trace.BadParameter("solve must be non-nil") + case params.EKCert == nil && params.EKKey == nil: + return nil, trace.BadParameter("at least one of EKCert or EKKey must be provided") + } + + ekCert, ekPub, err := parseEK(ctx, params) + if err != nil { + return nil, trace.Wrap(err) + } + + validated := &ValidatedTPM{} + ekPubPKIX, err := x509.MarshalPKIXPublicKey(ekPub) + if err != nil { + return nil, trace.Wrap(err) + } + validated.EKPubHash, err = hashEKPub(ekPubPKIX) + if err != nil { + return validated, trace.Wrap(err, "hashing EK public key") + } + if ekCert != nil { + validated.EKCertSerial = serialString(ekCert.SerialNumber) + } + + if params.AllowedCAs != nil { + if err := verifyEKCert(ctx, params.AllowedCAs, ekCert); err != nil { + return validated, trace.Wrap(err, "verifying EK cert") + } + validated.EKCertVerified = true + } + + activationParameters := attest.ActivationParameters{ + TPMVersion: attest.TPMVersion20, + AK: params.AttestParams, + EK: ekPub, + } + // The generate method completes initial validation that provides the + // following assurances: + // - The attestation key is of a secure length + // - The attestation key is marked as created within a TPM + // - The attestation key is marked as restricted (e.g cannot be used to + // sign or decrypt external data) + // When the returned challenge is solved by the TPM using ActivateCredential + // the following additional assurance is given: + // - The attestation key resides in the same TPM as the endorsement key + solution, encryptedCredential, err := activationParameters.Generate() + if err != nil { + return validated, trace.Wrap(err, "generating credential activation challenge") + } + clientSolution, err := params.Solve(encryptedCredential) + if err != nil { + return validated, trace.Wrap(err, "asking client to perform credential activation") + } + if subtle.ConstantTimeCompare(clientSolution, solution) != 1 { + return validated, trace.BadParameter("invalid credential activation solution") + } + + return validated, nil +} + +func parseEK( + ctx context.Context, params ValidateParams, +) (*x509.Certificate, crypto.PublicKey, error) { + _, span := tracer.Start(ctx, "parseEK") + defer span.End() + + ekCertPresent := len(params.EKCert) > 0 + ekKeyPresent := len(params.EKKey) > 0 + switch { + case ekCertPresent: + ekCert, err := attest.ParseEKCertificate(params.EKCert) + if err != nil { + return nil, nil, trace.Wrap(err, "parsing EK cert") + } + return ekCert, ekCert.PublicKey, nil + case ekKeyPresent: + ekPub, err := x509.ParsePKIXPublicKey(params.EKKey) + if err != nil { + return nil, nil, trace.Wrap(err, "parsing EK key") + } + return nil, ekPub, nil + default: + return nil, nil, trace.BadParameter("either EK cert or EK key must be provided") + } +} + +func verifyEKCert( + ctx context.Context, + allowedCAs *x509.CertPool, + ekCert *x509.Certificate, +) error { + _, span := tracer.Start(ctx, "verifyEKCert") + defer span.End() + + if ekCert == nil { + return trace.BadParameter("tpm did not provide an EKCert to validate against allowed CAs") + } + + // Validate EKCert against CA pool + _, err := ekCert.Verify(x509.VerifyOptions{ + Roots: allowedCAs, + KeyUsages: []x509.ExtKeyUsage{ + // Go's x509 Verification doesn't support the EK certificate + // ExtKeyUsage (http://oid-info.com/get/2.23.133.8.1), so we + // allow any. + x509.ExtKeyUsageAny, + }, + }) + if err != nil { + return trace.Wrap(err, "verifying EK cert") + } + return nil +}