diff --git a/lib/devicetrust/enroll/enroll.go b/lib/devicetrust/enroll/enroll.go index 316bb254aaf8c..63128069edf4f 100644 --- a/lib/devicetrust/enroll/enroll.go +++ b/lib/devicetrust/enroll/enroll.go @@ -19,8 +19,10 @@ import ( "runtime" "github.com/gravitational/trace" + "golang.org/x/exp/slices" devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/devicetrust" ) @@ -28,8 +30,15 @@ import ( func RunCeremony(ctx context.Context, devicesClient devicepb.DeviceTrustServiceClient, enrollToken string) (*devicepb.Device, error) { // Start by checking the OSType, this lets us exit early with a nicer message // for non-supported OSes. - if getOSType() != devicepb.OSType_OS_TYPE_MACOS { - return nil, trace.BadParameter("device enrollment not supported for current OS (%v)", runtime.GOOS) + osType := getOSType() + if !slices.Contains([]devicepb.OSType{ + devicepb.OSType_OS_TYPE_MACOS, + devicepb.OSType_OS_TYPE_WINDOWS, + }, osType) { + return nil, trace.BadParameter( + "device enrollment not supported for current OS (%s)", + types.ResourceOSTypeToString(osType), + ) } init, err := enrollInit() @@ -57,10 +66,21 @@ func RunCeremony(ctx context.Context, devicesClient devicepb.DeviceTrustServiceC // Unimplemented errors are not expected to happen after this point. // 2. Challenge. - // Only macOS is supported, see the guard at the beginning of the method. - if err := enrollDeviceMacOS(stream, resp); err != nil { + switch osType { + case devicepb.OSType_OS_TYPE_MACOS: + err = enrollDeviceMacOS(stream, resp) + // err handled below + case devicepb.OSType_OS_TYPE_WINDOWS: + err = enrollDeviceTPM(stream, resp) + // err handled below + default: + // This should be caught by the OSType guard at start of function. + panic("no enrollment function provided for os") + } + if err != nil { return nil, trace.Wrap(err) } + resp, err = stream.Recv() if err != nil { return nil, trace.Wrap(err) @@ -93,6 +113,29 @@ func enrollDeviceMacOS(stream devicepb.DeviceTrustService_EnrollDeviceClient, re return trace.Wrap(err) } +func enrollDeviceTPM(stream devicepb.DeviceTrustService_EnrollDeviceClient, resp *devicepb.EnrollDeviceResponse) error { + challenge := resp.GetTpmChallenge() + switch { + case challenge == nil: + return trace.BadParameter("unexpected challenge payload from server: %T", resp.Payload) + case challenge.EncryptedCredential == nil: + return trace.BadParameter("missing encrypted_credential in challenge from server") + case len(challenge.AttestationNonce) == 0: + return trace.BadParameter("missing attestation_nonce in challenge from server") + } + + challengeResponse, err := solveTPMEnrollChallenge(challenge) + if err != nil { + return trace.Wrap(err) + } + err = stream.Send(&devicepb.EnrollDeviceRequest{ + Payload: &devicepb.EnrollDeviceRequest_TpmChallengeResponse{ + TpmChallengeResponse: challengeResponse, + }, + }) + return trace.Wrap(err) +} + func getDeviceOSType() devicepb.OSType { switch runtime.GOOS { case "darwin": diff --git a/lib/devicetrust/enroll/enroll_test.go b/lib/devicetrust/enroll/enroll_test.go index a19a24ebdc954..b37d8d1b1b00f 100644 --- a/lib/devicetrust/enroll/enroll_test.go +++ b/lib/devicetrust/enroll/enroll_test.go @@ -19,6 +19,7 @@ import ( "os" "testing" + "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -37,14 +38,49 @@ func TestRunCeremony(t *testing.T) { macOSDev1, err := testenv.NewFakeMacOSDevice() require.NoError(t, err, "NewFakeMacOSDevice failed") + windowsDev1 := testenv.NewFakeWindowsDevice() tests := []struct { - name string - dev fakeDevice + name string + dev fakeDevice + assertErr func(t *testing.T, err error) + assertGotDevice func(t *testing.T, device *devicepb.Device) }{ { - name: "macOS device", + name: "macOS device succeeds", dev: macOSDev1, + assertErr: func(t *testing.T, err error) { + assert.NoError(t, err, "RunCeremony returned an error") + }, + assertGotDevice: func(t *testing.T, d *devicepb.Device) { + assert.NotNil(t, d, "RunCeremony returned nil device") + }, + }, + { + name: "windows device succeeds", + dev: windowsDev1, + assertErr: func(t *testing.T, err error) { + assert.NoError(t, err, "RunCeremony returned an error") + }, + assertGotDevice: func(t *testing.T, d *devicepb.Device) { + require.NotNil(t, d, "RunCeremony returned nil device") + require.NotNil(t, d.Credential, "device credential is nil") + assert.Equal(t, windowsDev1.CredentialID, d.Credential.Id, "device credential mismatch") + }, + }, + { + name: "linux device fails", + dev: testenv.NewFakeLinuxDevice(), + assertErr: func(t *testing.T, err error) { + require.Error(t, err) + assert.True( + t, trace.IsBadParameter(err), "RunCeremony did not return a BadParameter error", + ) + assert.ErrorContains(t, err, "linux", "RunCeremony error mismatch") + }, + assertGotDevice: func(t *testing.T, d *devicepb.Device) { + assert.Nil(t, d, "RunCeremony returned an unexpected, non-nil device") + }, }, } for _, test := range tests { @@ -52,10 +88,11 @@ func TestRunCeremony(t *testing.T) { *enroll.GetOSType = test.dev.GetOSType *enroll.EnrollInit = test.dev.EnrollDeviceInit *enroll.SignChallenge = test.dev.SignChallenge + *enroll.SolveTPMEnrollChallenge = test.dev.SolveTPMEnrollChallenge got, err := enroll.RunCeremony(ctx, devices, "faketoken") - require.NoError(t, err, "RunCeremony failed") - assert.NotNil(t, got, "RunCeremony returned nil device") + test.assertErr(t, err) + test.assertGotDevice(t, got) }) } } @@ -85,4 +122,5 @@ type fakeDevice interface { EnrollDeviceInit() (*devicepb.EnrollDeviceInit, error) GetOSType() devicepb.OSType SignChallenge(chal []byte) (sig []byte, err error) + SolveTPMEnrollChallenge(challenge *devicepb.TPMEnrollChallenge) (*devicepb.TPMEnrollChallengeResponse, error) } diff --git a/lib/devicetrust/enroll/export_test.go b/lib/devicetrust/enroll/export_test.go index 555a1b809ec8b..313d3a8a85e0f 100644 --- a/lib/devicetrust/enroll/export_test.go +++ b/lib/devicetrust/enroll/export_test.go @@ -15,8 +15,9 @@ package enroll var ( - CollectDeviceData = &collectDeviceData - EnrollInit = &enrollInit - GetOSType = &getOSType - SignChallenge = &signChallenge + CollectDeviceData = &collectDeviceData + EnrollInit = &enrollInit + GetOSType = &getOSType + SignChallenge = &signChallenge + SolveTPMEnrollChallenge = &solveTPMEnrollChallenge ) diff --git a/lib/devicetrust/enroll/native_shim.go b/lib/devicetrust/enroll/native_shim.go index d487fda85c9e9..42aa063bfecdf 100644 --- a/lib/devicetrust/enroll/native_shim.go +++ b/lib/devicetrust/enroll/native_shim.go @@ -18,8 +18,9 @@ import "github.com/gravitational/teleport/lib/devicetrust/native" // vars below are used to fake OSes and switch implementations for tests. var ( - collectDeviceData = native.CollectDeviceData - enrollInit = native.EnrollDeviceInit - getOSType = getDeviceOSType - signChallenge = native.SignChallenge + collectDeviceData = native.CollectDeviceData + enrollInit = native.EnrollDeviceInit + getOSType = getDeviceOSType + signChallenge = native.SignChallenge + solveTPMEnrollChallenge = native.SolveTPMEnrollChallenge ) diff --git a/lib/devicetrust/native/api.go b/lib/devicetrust/native/api.go index 4e5463d10a4af..0306735bf3dd9 100644 --- a/lib/devicetrust/native/api.go +++ b/lib/devicetrust/native/api.go @@ -14,7 +14,9 @@ package native -import devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" +import ( + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" +) // EnrollDeviceInit creates the initial enrollment data for the device. // This includes fetching or creating a device credential, collecting device @@ -39,3 +41,8 @@ func SignChallenge(chal []byte) (sig []byte, err error) { func GetDeviceCredential() (*devicepb.DeviceCredential, error) { return getDeviceCredential() } + +// SolveTPMEnrollChallenge completes a TPM enrollment challenge. +func SolveTPMEnrollChallenge(challenge *devicepb.TPMEnrollChallenge) (*devicepb.TPMEnrollChallengeResponse, error) { + return solveTPMEnrollChallenge(challenge) +} diff --git a/lib/devicetrust/native/device_darwin.go b/lib/devicetrust/native/device_darwin.go index fafd7151a93e0..b16f02988ee09 100644 --- a/lib/devicetrust/native/device_darwin.go +++ b/lib/devicetrust/native/device_darwin.go @@ -97,10 +97,12 @@ func collectDeviceData() (*devicepb.DeviceCollectedData, error) { return nil, trace.Wrap(statusErrorFromC(res)) } + sn := C.GoString(dd.serial_number) return &devicepb.DeviceCollectedData{ - CollectTime: timestamppb.Now(), - OsType: devicepb.OSType_OS_TYPE_MACOS, - SerialNumber: C.GoString(dd.serial_number), + CollectTime: timestamppb.Now(), + OsType: devicepb.OSType_OS_TYPE_MACOS, + SerialNumber: sn, + SystemSerialNumber: sn, }, nil } @@ -142,3 +144,7 @@ func getDeviceCredential() (*devicepb.DeviceCredential, error) { func statusErrorFromC(res C.int32_t) error { return &statusError{status: int32(res)} } + +func solveTPMEnrollChallenge(challenge *devicepb.TPMEnrollChallenge) (*devicepb.TPMEnrollChallengeResponse, error) { + return nil, trace.BadParameter("called solveTPMEnrollChallenge on darwin") +} diff --git a/lib/devicetrust/native/device_windows.go b/lib/devicetrust/native/device_windows.go new file mode 100644 index 0000000000000..13741756b7152 --- /dev/null +++ b/lib/devicetrust/native/device_windows.go @@ -0,0 +1,487 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package native + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "math/big" + "os" + "os/exec" + "os/user" + "path" + "strings" + + "github.com/google/go-attestation/attest" + "github.com/gravitational/trace" + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" + + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" + "github.com/gravitational/teleport/lib/devicetrust" +) + +const ( + deviceStateFolderName = ".teleport-device" + attestationKeyFileName = "attestation.key" +) + +// setupDeviceStateDir ensures that device state directory exists. +// It returns the absolute path to where the attestation key can be found: +// ~/teleport-device/attestation.key +func setupDeviceStateDir(getHomeDir func() (string, error)) (akPath string, err error) { + home, err := getHomeDir() + if err != nil { + return "", trace.Wrap(err) + } + + deviceStateDirPath := path.Join(home, deviceStateFolderName) + keyPath := path.Join(deviceStateDirPath, attestationKeyFileName) + + if _, err := os.Stat(deviceStateDirPath); err != nil { + if os.IsNotExist(err) { + // If it doesn't exist, we can create it and return as we know + // the perms are correct as we created it. + if err := os.Mkdir(deviceStateDirPath, 700); err != nil { + return "", trace.Wrap(err) + } + return keyPath, nil + } + return "", trace.Wrap(err) + } + + return keyPath, nil +} + +// getMarshaledEK returns the EK public key in PKIX, ASN.1 DER format. +func getMarshaledEK(tpm *attest.TPM) ([]byte, error) { + eks, err := tpm.EKs() + if err != nil { + return nil, trace.Wrap(err) + } + if len(eks) == 0 { + // This is a pretty unusual case, `go-attestation` will attempt to + // create an EK if no EK Certs are present in the NVRAM of the TPM. + // Either way, it lets us catch this early in case `go-attestation` + // misbehaves. + return nil, trace.BadParameter("no endorsement keys found in tpm") + } + // 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. + // TODO(noah): Marshal EK Certificate instead of key if present: + // https://github.com/gravitational/teleport.e/issues/1393 + encodedEK, err := x509.MarshalPKIXPublicKey(eks[0].Public) + return encodedEK, trace.Wrap(err) +} + +// loadAK attempts to load an AK from disk. A NotFound error will be +// returned if no such file exists. +func loadAK( + tpm *attest.TPM, + persistencePath string, +) (*attest.AK, error) { + ref, err := os.ReadFile(persistencePath) + if err != nil { + return nil, trace.ConvertSystemError(err) + } + + ak, err := tpm.LoadAK(ref) + if err != nil { + return nil, trace.Wrap(err, "loading ak into tpm") + } + + return ak, nil +} + +func createAndSaveAK( + tpm *attest.TPM, + persistencePath string, +) (*attest.AK, error) { + ak, err := tpm.NewAK(&attest.AKConfig{}) + if err != nil { + return nil, trace.Wrap(err, "creating ak") + } + + // Write it to the well-known location on disk + ref, err := ak.Marshal() + if err != nil { + return nil, trace.Wrap(err, "marshalling ak") + } + err = os.WriteFile(persistencePath, ref, 600) + if err != nil { + return nil, trace.Wrap(err, "writing ak to disk") + } + + return ak, nil +} + +func enrollDeviceInit() (*devicepb.EnrollDeviceInit, error) { + akPath, err := setupDeviceStateDir(os.UserHomeDir) + if err != nil { + return nil, trace.Wrap(err, "setting up device state directory") + } + + tpm, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + }) + if err != nil { + return nil, trace.Wrap(err, "opening tpm") + } + defer tpm.Close() + + // Try to load an existing AK in the case of re-enrollment, but, if the + // AK does not exist, create one and persist it. + ak, err := loadAK(tpm, akPath) + if err != nil { + if !trace.IsNotFound(err) { + return nil, trace.Wrap(err, "loading ak") + } + log.Debug("TPM: No existing AK was found on disk, an AK will be created.") + ak, err = createAndSaveAK(tpm, akPath) + if err != nil { + return nil, trace.Wrap(err, "creating ak") + } + } else { + log.Debug("TPM: Existing AK was found on disk, it will be reused.") + } + defer ak.Close(tpm) + + deviceData, err := collectDeviceData() + if err != nil { + return nil, trace.Wrap(err, "collecting device data") + } + + marshaledEK, err := getMarshaledEK(tpm) + if err != nil { + return nil, trace.Wrap(err, "marshalling ek") + } + + credentialID, err := credentialIDFromAK(ak) + if err != nil { + return nil, trace.Wrap(err, "determining credential id") + } + + return &devicepb.EnrollDeviceInit{ + CredentialId: credentialID, + DeviceData: deviceData, + Tpm: &devicepb.TPMEnrollPayload{ + Ek: &devicepb.TPMEnrollPayload_EkKey{ + EkKey: marshaledEK, + }, + AttestationParameters: devicetrust.AttestationParametersToProto( + ak.AttestationParameters(), + ), + }, + }, nil +} + +// credentialIDFromAK produces a deterministic, short-ish, unique-ish, printable +// string identifier for a given AK. This can then be used as a reference for +// this AK in the backend. +// +// To produce this, we perform a SHA256 hash over the constituent fields of +// the AKs public key and then base64 encode it to produce a human-readable +// string. This is similar to how SSH fingerprinting of public keys work. +func credentialIDFromAK(ak *attest.AK) (string, error) { + akPub, err := attest.ParseAKPublic( + attest.TPMVersion20, + ak.AttestationParameters().Public, + ) + if err != nil { + return "", trace.Wrap(err, "parsing ak public") + } + publicKey := akPub.Public + switch publicKey := publicKey.(type) { + // at this time `go-attestation` only creates RSA 2048bit Attestation Keys. + case *rsa.PublicKey: + h := sha256.New() + // This logic is roughly based off the openssh key fingerprinting, + // but, the hash excludes "ssh-rsa" and the outputted id is not + // prepended with "SHA256": + // + // It is imperative the order of the fields does not change in future + // implementations. + h.Write(big.NewInt(int64(publicKey.E)).Bytes()) + h.Write(publicKey.N.Bytes()) + return base64.RawStdEncoding.EncodeToString(h.Sum(nil)), nil + default: + return "", trace.BadParameter("unsupported public key type: %T", publicKey) + } +} + +// getDeviceSerial returns the serial number of the device using PowerShell to +// grab the correct WMI objects. Getting it without calling into PS is possible, +// but requires interfacing with the ancient Win32 COM APIs. +func getDeviceSerial() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_BIOS | Select -ExpandProperty SerialNumber", + ) + // ThinkPad P P14s: + // PS > Get-WmiObject Win32_BIOS | Select -ExpandProperty SerialNumber + // PF47WND6 + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + return strings.TrimSpace(string(out)), nil +} + +func getReportedAssetTag() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_SystemEnclosure | Select -ExpandProperty SMBIOSAssetTag", + ) + // ThinkPad P P14s: + // PS > Get-WmiObject Win32_SystemEnclosure | Select -ExpandProperty SMBIOSAssetTag + // winaia_1337 + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + return strings.TrimSpace(string(out)), nil +} + +func getDeviceModel() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_ComputerSystem | Select -ExpandProperty Model", + ) + // ThinkPad P P14s: + // PS> Get-WmiObject Win32_ComputerSystem | Select -ExpandProperty Model + // 21J50013US + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + return strings.TrimSpace(string(out)), nil +} + +func getDeviceBaseBoardSerial() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_BaseBoard | Select -ExpandProperty SerialNumber", + ) + // ThinkPad P P14s: + // PS> Get-WmiObject Win32_BaseBoard | Select -ExpandProperty SerialNumber + // L1HF2CM03ZT + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + + return strings.TrimSpace(string(out)), nil +} + +func getOSVersion() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_OperatingSystem | Select -ExpandProperty Version", + ) + // ThinkPad P P14s: + // PS> Get-WmiObject Win32_OperatingSystem | Select -ExpandProperty Version + // 10.0.22621 + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + + return strings.TrimSpace(string(out)), nil +} + +func getOSBuildNumber() (string, error) { + cmd := exec.Command( + "powershell", + "-NoProfile", + "Get-WmiObject Win32_OperatingSystem | Select -ExpandProperty BuildNumber", + ) + // ThinkPad P P14s: + // PS> Get-WmiObject Win32_OperatingSystem | Select -ExpandProperty BuildNumber + // 22621 + out, err := cmd.Output() + if err != nil { + return "", trace.Wrap(err) + } + + return strings.TrimSpace(string(out)), nil +} + +func firstOf(strings ...string) string { + for _, str := range strings { + if str != "" { + return str + } + } + return "" +} + +func collectDeviceData() (*devicepb.DeviceCollectedData, error) { + log.Debug("TPM: Collecting device data.") + systemSerial, err := getDeviceSerial() + if err != nil { + return nil, trace.Wrap(err, "fetching system serial") + } + model, err := getDeviceModel() + if err != nil { + return nil, trace.Wrap(err, "fetching device model") + } + baseBoardSerial, err := getDeviceBaseBoardSerial() + if err != nil { + return nil, trace.Wrap(err, "fetching base board serial") + } + reportedAssetTag, err := getReportedAssetTag() + if err != nil { + return nil, trace.Wrap(err, "fetching reported asset tag") + } + osVersion, err := getOSVersion() + if err != nil { + return nil, trace.Wrap(err, "fetching os version") + } + osBuildNumber, err := getOSBuildNumber() + if err != nil { + return nil, trace.Wrap(err, "fetching os build number") + } + u, err := user.Current() + if err != nil { + return nil, trace.Wrap(err, "fetching user") + } + + serial := firstOf(reportedAssetTag, systemSerial, baseBoardSerial) + if serial == "" { + return nil, trace.BadParameter("unable to determine serial number") + } + + dcd := &devicepb.DeviceCollectedData{ + CollectTime: timestamppb.Now(), + OsType: devicepb.OSType_OS_TYPE_WINDOWS, + SerialNumber: serial, + ModelIdentifier: model, + OsUsername: u.Username, + OsVersion: osVersion, + OsBuild: osBuildNumber, + SystemSerialNumber: systemSerial, + BaseBoardSerialNumber: baseBoardSerial, + ReportedAssetTag: reportedAssetTag, + } + log.WithField( + "device_collected_data", dcd, + ).Debug("TPM: Device data collected.") + return dcd, nil +} + +// getDeviceCredential will only return the credential ID on windows. The +// other information is determined server-side. +func getDeviceCredential() (*devicepb.DeviceCredential, error) { + akPath, err := setupDeviceStateDir(os.UserHomeDir) + if err != nil { + return nil, trace.Wrap(err, "setting up device state directory") + } + tpm, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + }) + if err != nil { + return nil, trace.Wrap(err, "opening tpm") + } + defer tpm.Close() + + // Attempt to load the AK from well-known location. + ak, err := loadAK(tpm, akPath) + if err != nil { + return nil, trace.Wrap(err, "loading ak") + } + defer ak.Close(tpm) + + credentialID, err := credentialIDFromAK(ak) + if err != nil { + return nil, trace.Wrap(err, "determining credential id") + } + + return &devicepb.DeviceCredential{ + Id: credentialID, + }, nil +} + +func solveTPMEnrollChallenge( + challenge *devicepb.TPMEnrollChallenge, +) (*devicepb.TPMEnrollChallengeResponse, error) { + akPath, err := setupDeviceStateDir(os.UserHomeDir) + if err != nil { + return nil, trace.Wrap(err, "setting up device state directory") + } + + tpm, err := attest.OpenTPM(&attest.OpenConfig{ + TPMVersion: attest.TPMVersion20, + }) + if err != nil { + return nil, trace.Wrap(err, "opening tpm") + } + defer tpm.Close() + + // Attempt to load the AK from well-known location. + ak, err := loadAK(tpm, akPath) + if err != nil { + return nil, trace.Wrap(err, "loading ak") + } + defer ak.Close(tpm) + + // Next perform a platform attestation using the AK. + log.Debug("TPM: Performing platform attestation.") + platformsParams, err := tpm.AttestPlatform( + ak, + challenge.AttestationNonce, + &attest.PlatformAttestConfig{ + // EventLog == nil indicates that the `attest` package is + // responsible for providing the eventlog. + EventLog: nil, + }, + ) + if err != nil { + return nil, trace.Wrap(err, "attesting platform") + } + + // First perform the credential activation challenge provided by the + // auth server. + log.Debug("TPM: Activating credential.") + activationSolution, err := ak.ActivateCredential( + tpm, + devicetrust.EncryptedCredentialFromProto(challenge.EncryptedCredential), + ) + if err != nil { + return nil, trace.Wrap(err, "activating credential with challenge") + } + + log.Debug("TPM: Enrollment challenge completed.") + return &devicepb.TPMEnrollChallengeResponse{ + Solution: activationSolution, + PlatformParameters: devicetrust.PlatformParametersToProto( + platformsParams, + ), + }, nil +} + +// signChallenge is not implemented on windows as TPM platform attestation +// is used instead. +func signChallenge(_ []byte) (sig []byte, err error) { + return nil, trace.BadParameter("called signChallenge on windows") +} diff --git a/lib/devicetrust/native/others.go b/lib/devicetrust/native/others.go index dfde3787408c3..6aa272979369f 100644 --- a/lib/devicetrust/native/others.go +++ b/lib/devicetrust/native/others.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows // Copyright 2022 Gravitational, Inc // @@ -36,3 +36,9 @@ func signChallenge(chal []byte) (sig []byte, err error) { func getDeviceCredential() (*devicepb.DeviceCredential, error) { return nil, devicetrust.ErrPlatformNotSupported } + +func solveTPMEnrollChallenge( + _ *devicepb.TPMEnrollChallenge, +) (*devicepb.TPMEnrollChallengeResponse, error) { + return nil, devicetrust.ErrPlatformNotSupported +} diff --git a/lib/devicetrust/testenv/fake_device_service.go b/lib/devicetrust/testenv/fake_device_service.go index 7ddcf02fbb7a4..9e00d08b9d25a 100644 --- a/lib/devicetrust/testenv/fake_device_service.go +++ b/lib/devicetrust/testenv/fake_device_service.go @@ -15,6 +15,7 @@ package testenv import ( + "bytes" "context" "crypto/ecdsa" "crypto/rand" @@ -24,6 +25,7 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" @@ -90,10 +92,18 @@ func (s *fakeDeviceService) EnrollDevice(stream devicepb.DeviceTrustService_Enro } // OS-specific enrollment. - if initReq.DeviceData.OsType != devicepb.OSType_OS_TYPE_MACOS { + var cred *devicepb.DeviceCredential + var pub *ecdsa.PublicKey + switch initReq.DeviceData.OsType { + case devicepb.OSType_OS_TYPE_MACOS: + cred, pub, err = enrollMacOS(stream, initReq) + // err handled below + case devicepb.OSType_OS_TYPE_WINDOWS: + cred, err = enrollTPM(stream, initReq) + // err handled below + default: return trace.BadParameter("os not supported") } - cred, pub, err := enrollMacOS(stream, initReq) if err != nil { return trace.Wrap(err) } @@ -129,6 +139,71 @@ func (s *fakeDeviceService) EnrollDevice(stream devicepb.DeviceTrustService_Enro return trace.Wrap(err) } +func randomBytes() ([]byte, error) { + buf := make([]byte, 32) + _, err := rand.Read(buf) + return buf, err +} + +func enrollTPM(stream devicepb.DeviceTrustService_EnrollDeviceServer, initReq *devicepb.EnrollDeviceInit) (*devicepb.DeviceCredential, error) { + switch { + case initReq.Tpm == nil: + return nil, trace.BadParameter("init req missing tpm message") + case !bytes.Equal(validEKKey, initReq.Tpm.GetEkKey()): + return nil, trace.BadParameter("ek key in init req did not match expected") + case !proto.Equal(initReq.Tpm.AttestationParameters, validAttestationParameters): + return nil, trace.BadParameter("init req tpm message attestation parameters mismatch") + } + + secret, err := randomBytes() + if err != nil { + return nil, trace.Wrap(err) + } + credentialBlob, err := randomBytes() + if err != nil { + return nil, trace.Wrap(err) + } + expectSolution := append(secret, credentialBlob...) + nonce, err := randomBytes() + if err != nil { + return nil, trace.Wrap(err) + } + if err := stream.Send(&devicepb.EnrollDeviceResponse{ + Payload: &devicepb.EnrollDeviceResponse_TpmChallenge{ + TpmChallenge: &devicepb.TPMEnrollChallenge{ + EncryptedCredential: &devicepb.TPMEncryptedCredential{ + CredentialBlob: credentialBlob, + Secret: secret, + }, + AttestationNonce: nonce, + }, + }, + }); err != nil { + return nil, trace.Wrap(err) + } + + resp, err := stream.Recv() + if err != nil { + return nil, trace.Wrap(err) + } + chalResp := resp.GetTpmChallengeResponse() + switch { + case chalResp == nil: + return nil, trace.BadParameter("challenge response required") + case !bytes.Equal(expectSolution, chalResp.Solution): + return nil, trace.BadParameter("activate credential solution in challenge response did not match expected") + case chalResp.PlatformParameters == nil: + return nil, trace.BadParameter("missing platform parameters in challenge response") + case !bytes.Equal(nonce, chalResp.PlatformParameters.EventLog): + return nil, trace.BadParameter("nonce in challenge response did not match expected") + } + + return &devicepb.DeviceCredential{ + Id: initReq.CredentialId, + DeviceAttestationType: devicepb.DeviceAttestationType_DEVICE_ATTESTATION_TYPE_TPM_EKPUB, + }, nil +} + func enrollMacOS(stream devicepb.DeviceTrustService_EnrollDeviceServer, initReq *devicepb.EnrollDeviceInit) (*devicepb.DeviceCredential, *ecdsa.PublicKey, error) { switch { case initReq.Macos == nil: diff --git a/lib/devicetrust/testenv/fake_linux_device.go b/lib/devicetrust/testenv/fake_linux_device.go new file mode 100644 index 0000000000000..167b27583b1f6 --- /dev/null +++ b/lib/devicetrust/testenv/fake_linux_device.go @@ -0,0 +1,49 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testenv + +import ( + "github.com/gravitational/trace" + + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" +) + +// FakeLinuxDevice only implements GetOSType Linux OS type so we can be sure +// this fails in a user friendly manner. +type FakeLinuxDevice struct{} + +func NewFakeLinuxDevice() *FakeLinuxDevice { + return &FakeLinuxDevice{} +} + +func (d *FakeLinuxDevice) GetOSType() devicepb.OSType { + return devicepb.OSType_OS_TYPE_LINUX +} + +func (d *FakeLinuxDevice) CollectDeviceData() (*devicepb.DeviceCollectedData, error) { + return nil, trace.NotImplemented("linux device fake unimplemented") +} + +func (d *FakeLinuxDevice) EnrollDeviceInit() (*devicepb.EnrollDeviceInit, error) { + return nil, trace.NotImplemented("linux device fake unimplemented") +} + +func (d *FakeLinuxDevice) SignChallenge(_ []byte) (sig []byte, err error) { + return nil, trace.NotImplemented("linux device fake unimplemented") +} + +func (d *FakeLinuxDevice) SolveTPMEnrollChallenge(_ *devicepb.TPMEnrollChallenge) (*devicepb.TPMEnrollChallengeResponse, error) { + return nil, trace.NotImplemented("linux device fake unimplemented") +} diff --git a/lib/devicetrust/testenv/fake_macos_device.go b/lib/devicetrust/testenv/fake_macos_device.go index bdfda2970bdfa..e27f1b3334f05 100644 --- a/lib/devicetrust/testenv/fake_macos_device.go +++ b/lib/devicetrust/testenv/fake_macos_device.go @@ -22,6 +22,7 @@ import ( "crypto/x509" "github.com/google/uuid" + "github.com/gravitational/trace" "google.golang.org/protobuf/types/known/timestamppb" devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" @@ -91,3 +92,9 @@ func (f *FakeMacOSDevice) SignChallenge(chal []byte) (sig []byte, err error) { h := sha256.Sum256(chal) return ecdsa.SignASN1(rand.Reader, f.privKey, h[:]) } + +func (d *FakeMacOSDevice) SolveTPMEnrollChallenge( + _ *devicepb.TPMEnrollChallenge, +) (*devicepb.TPMEnrollChallengeResponse, error) { + return nil, trace.NotImplemented("mac device does not implement SolveTPMEnrollChallenge") +} diff --git a/lib/devicetrust/testenv/fake_windows_device.go b/lib/devicetrust/testenv/fake_windows_device.go new file mode 100644 index 0000000000000..d57043a5f9af7 --- /dev/null +++ b/lib/devicetrust/testenv/fake_windows_device.go @@ -0,0 +1,96 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testenv + +import ( + "github.com/google/uuid" + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/timestamppb" + + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" +) + +// FakeWindowsDevice allows us to exercise EnrollCeremony. To avoid requiring +// dependencies to support a TPM simulator, we currently do not closely emulate +// the behavior of a real windows device. +// TODO(noah): When the underlying implementation in `native/` is refactored to +// share code between Windows & Linux, it will be a good opportunity to refactor +// this implementation to be more realistic. +type FakeWindowsDevice struct { + CredentialID string + SerialNumber string +} + +func NewFakeWindowsDevice() *FakeWindowsDevice { + return &FakeWindowsDevice{ + CredentialID: uuid.NewString(), + SerialNumber: uuid.NewString(), + } +} + +func (f *FakeWindowsDevice) GetOSType() devicepb.OSType { + return devicepb.OSType_OS_TYPE_WINDOWS +} + +func (f *FakeWindowsDevice) CollectDeviceData() (*devicepb.DeviceCollectedData, error) { + return &devicepb.DeviceCollectedData{ + CollectTime: timestamppb.Now(), + OsType: devicepb.OSType_OS_TYPE_WINDOWS, + SerialNumber: f.SerialNumber, + }, nil +} + +var validEKKey = []byte("FAKE_VALID_EK_KEY") +var validAttestationParameters = &devicepb.TPMAttestationParameters{ + Public: []byte("FAKE_TPMT_PUBLIC_FOR_AK"), +} + +func (f *FakeWindowsDevice) EnrollDeviceInit() (*devicepb.EnrollDeviceInit, error) { + cd, _ := f.CollectDeviceData() + return &devicepb.EnrollDeviceInit{ + CredentialId: f.CredentialID, + DeviceData: cd, + Tpm: &devicepb.TPMEnrollPayload{ + Ek: &devicepb.TPMEnrollPayload_EkKey{ + EkKey: validEKKey, + }, + AttestationParameters: validAttestationParameters, + }, + }, nil +} + +func (f *FakeWindowsDevice) SolveTPMEnrollChallenge( + challenge *devicepb.TPMEnrollChallenge, +) (*devicepb.TPMEnrollChallengeResponse, error) { + // This extremely roughly mimics the actual TPM by using the values + // provided in the encrypted credential to produce an activation challenge + // "solution", and uses the provided nonce in a fake platform attestation. + // This lets us assert from the server that the `SolveTPMEnrollChallenge` + // is provided all the values from the server by `RunCeremony`. + solution := append( + challenge.EncryptedCredential.Secret, + challenge.EncryptedCredential.CredentialBlob..., + ) + return &devicepb.TPMEnrollChallengeResponse{ + Solution: solution, + PlatformParameters: &devicepb.TPMPlatformParameters{ + EventLog: challenge.AttestationNonce, + }, + }, nil +} + +func (f *FakeWindowsDevice) SignChallenge(_ []byte) (sig []byte, err error) { + return nil, trace.NotImplemented("windows does not implement SignChallenge") +}