diff --git a/lib/devicetrust/authn/authn_test.go b/lib/devicetrust/authn/authn_test.go index 0013cf462bbc4..98577971e64f6 100644 --- a/lib/devicetrust/authn/authn_test.go +++ b/lib/devicetrust/authn/authn_test.go @@ -28,7 +28,9 @@ import ( ) func TestRunCeremony(t *testing.T) { - env := testenv.MustNew() + env := testenv.MustNew( + testenv.WithAutoCreateDevice(true), + ) defer env.Close() devices := env.DevicesClient @@ -99,7 +101,7 @@ func enrollDevice(ctx context.Context, devices devicepb.DeviceTrustServiceClient if err != nil { return fmt.Errorf("enroll device init: %w", err) } - enrollDeviceInit.Token = "fake device token" + enrollDeviceInit.Token = testenv.FakeEnrollmentToken if err := stream.Send(&devicepb.EnrollDeviceRequest{ Payload: &devicepb.EnrollDeviceRequest_Init{ Init: enrollDeviceInit, diff --git a/lib/devicetrust/enroll/auto_enroll_test.go b/lib/devicetrust/enroll/auto_enroll_test.go index f6bc998860244..292268cc5ba7c 100644 --- a/lib/devicetrust/enroll/auto_enroll_test.go +++ b/lib/devicetrust/enroll/auto_enroll_test.go @@ -26,7 +26,9 @@ import ( ) func TestAutoEnrollCeremony_Run(t *testing.T) { - env := testenv.MustNew() + env := testenv.MustNew( + testenv.WithAutoCreateDevice(true), + ) defer env.Close() devices := env.DevicesClient diff --git a/lib/devicetrust/enroll/enroll.go b/lib/devicetrust/enroll/enroll.go index 75ae948c77df0..a365d013b5e40 100644 --- a/lib/devicetrust/enroll/enroll.go +++ b/lib/devicetrust/enroll/enroll.go @@ -18,6 +18,8 @@ import ( "context" "github.com/gravitational/trace" + "github.com/gravitational/trace/trail" + log "github.com/sirupsen/logrus" "golang.org/x/exp/slices" devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" @@ -49,10 +51,114 @@ func NewCeremony() *Ceremony { } } -// RunCeremony performs the client-side device enrollment ceremony. -// Equivalent to `NewCeremony().Run()`. -func RunCeremony(ctx context.Context, devicesClient devicepb.DeviceTrustServiceClient, debug bool, enrollToken string) (*devicepb.Device, error) { - return NewCeremony().Run(ctx, devicesClient, debug, enrollToken) +// RunAdminOutcome is the outcome of [Ceremony.RunAdmin]. +// It is used to communicate the actions performed. +type RunAdminOutcome int + +const ( + _ RunAdminOutcome = iota // Zero means nothing happened. + DeviceEnrolled + DeviceRegistered + DeviceRegisteredAndEnrolled +) + +// RunAdmin is a more powerful variant of Run: it attempts to register the +// current device, creates an enrollment token and uses that token to call Run. +// +// Must be called by a user capable of performing all actions above, otherwise +// it fails. +// +// Returns the created or enrolled device, an outcome marker and an error. The +// zero outcome means everything failed. +// +// Note that the device may be created and the ceremony can still fail +// afterwards, causing a return similar to "return dev, DeviceRegistered, err" +// (where nothing is "nil"). +func (c *Ceremony) RunAdmin( + ctx context.Context, + devicesClient devicepb.DeviceTrustServiceClient, + debug bool, +) (*devicepb.Device, RunAdminOutcome, error) { + // The init message contains the device collected data. + init, err := c.EnrollDeviceInit() + if err != nil { + return nil, 0, trace.Wrap(err) + } + cdd := init.DeviceData + osType := cdd.OsType + assetTag := cdd.SerialNumber + + rewordAccessDenied := func(err error, action string) error { + if trace.IsAccessDenied(trail.FromGRPC(err)) { + log.WithError(err).Debug( + "Device Trust: Redacting access denied error with user-friendly message") + return trace.AccessDenied( + "User does not have permissions to %s. Contact your cluster device administrator.", + action, + ) + } + return err + } + + // Query for current device. + findResp, err := devicesClient.FindDevices(ctx, &devicepb.FindDevicesRequest{ + IdOrTag: assetTag, + }) + if err != nil { + return nil, 0, trace.Wrap(rewordAccessDenied(err, "list devices")) + } + var currentDev *devicepb.Device + for _, dev := range findResp.Devices { + if dev.OsType == osType { + currentDev = dev + log.Debugf( + "Device Trust: Found device %q/%v, id=%q", + currentDev.AssetTag, devicetrust.FriendlyOSType(currentDev.OsType), currentDev.Id, + ) + break + } + } + + // If missing, create the device. + var outcome RunAdminOutcome + if currentDev == nil { + currentDev, err = devicesClient.CreateDevice(ctx, &devicepb.CreateDeviceRequest{ + Device: &devicepb.Device{ + OsType: osType, + AssetTag: assetTag, + }, + CreateEnrollToken: true, // Save an additional RPC. + }) + if err != nil { + return nil, outcome, trace.Wrap(rewordAccessDenied(err, "register devices")) + } + outcome = DeviceRegistered + } + // From here onwards, always return `currentDev` and `outcome`! + + // If missing, create a new enrollment token. + if currentDev.EnrollToken.GetToken() == "" { + currentDev.EnrollToken, err = devicesClient.CreateDeviceEnrollToken(ctx, &devicepb.CreateDeviceEnrollTokenRequest{ + DeviceId: currentDev.Id, + }) + if err != nil { + return currentDev, outcome, trace.Wrap(rewordAccessDenied(err, "create device enrollment tokens")) + } + log.Debugf( + "Device Trust: Created enrollment token for device %q/%s", + currentDev.AssetTag, + devicetrust.FriendlyOSType(currentDev.OsType)) + } + token := currentDev.EnrollToken.GetToken() + + // Then proceed onto enrollment. + enrolled, err := c.Run(ctx, devicesClient, debug, token) + if err != nil { + return enrolled, outcome, trace.Wrap(err) + } + + outcome++ // "0" becomes "Enrolled", "Registered" becomes "RegisteredAndEnrolled". + return enrolled, outcome, trace.Wrap(err) } // Run performs the client-side device enrollment ceremony. diff --git a/lib/devicetrust/enroll/enroll_test.go b/lib/devicetrust/enroll/enroll_test.go index 001f80afd8dee..3d9b94fc299e9 100644 --- a/lib/devicetrust/enroll/enroll_test.go +++ b/lib/devicetrust/enroll/enroll_test.go @@ -27,13 +27,70 @@ import ( "github.com/gravitational/teleport/lib/devicetrust/testenv" ) -func TestCeremony_Run(t *testing.T) { +func TestCeremony_RunAdmin(t *testing.T) { env := testenv.MustNew() defer env.Close() devices := env.DevicesClient ctx := context.Background() + nonExistingDev, err := testenv.NewFakeMacOSDevice() + require.NoError(t, err, "NewFakeMacOSDevice failed") + + registeredDev, err := testenv.NewFakeMacOSDevice() + require.NoError(t, err, "NewFakeMacOSDevice failed") + + // Create the device corresponding to registeredDev. + _, err = devices.CreateDevice(ctx, &devicepb.CreateDeviceRequest{ + Device: &devicepb.Device{ + OsType: registeredDev.GetDeviceOSType(), + AssetTag: registeredDev.SerialNumber, + }, + }) + require.NoError(t, err, "CreateDevice(registeredDev) failed") + + tests := []struct { + name string + dev testenv.FakeDevice + wantOutcome enroll.RunAdminOutcome + }{ + { + name: "non-existing device", + dev: nonExistingDev, + wantOutcome: enroll.DeviceRegisteredAndEnrolled, + }, + { + name: "registered device", + dev: registeredDev, + wantOutcome: enroll.DeviceEnrolled, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &enroll.Ceremony{ + GetDeviceOSType: test.dev.GetDeviceOSType, + EnrollDeviceInit: test.dev.EnrollDeviceInit, + SignChallenge: test.dev.SignChallenge, + SolveTPMEnrollChallenge: test.dev.SolveTPMEnrollChallenge, + } + + enrolled, outcome, err := c.RunAdmin(ctx, devices, false /* debug */) + require.NoError(t, err, "RunAdmin failed") + assert.NotNil(t, enrolled, "RunAdmin returned nil device") + assert.Equal(t, test.wantOutcome, outcome, "RunAdmin outcome mismatch") + }) + } +} + +func TestCeremony_Run(t *testing.T) { + env := testenv.MustNew( + testenv.WithAutoCreateDevice(true), + ) + defer env.Close() + + devices := env.DevicesClient + ctx := context.Background() + macOSDev1, err := testenv.NewFakeMacOSDevice() require.NoError(t, err, "NewFakeMacOSDevice failed") windowsDev1 := testenv.NewFakeWindowsDevice() @@ -90,7 +147,7 @@ func TestCeremony_Run(t *testing.T) { SolveTPMEnrollChallenge: test.dev.SolveTPMEnrollChallenge, } - got, err := c.Run(ctx, devices, false, "faketoken") + got, err := c.Run(ctx, devices, false /* debug */, testenv.FakeEnrollmentToken) test.assertErr(t, err) test.assertGotDevice(t, got) }) diff --git a/lib/devicetrust/testenv/fake_device_service.go b/lib/devicetrust/testenv/fake_device_service.go index 318092dfe2f65..dceed46d58da7 100644 --- a/lib/devicetrust/testenv/fake_device_service.go +++ b/lib/devicetrust/testenv/fake_device_service.go @@ -32,14 +32,23 @@ import ( devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" ) +// FakeEnrollmentToken is a "free", never spent enrollment token. +const FakeEnrollmentToken = "29d73573-1682-42a1-b28f-c0e42a29942f" + type storedDevice struct { - pb *devicepb.Device - pub *ecdsa.PublicKey + pb *devicepb.Device + pub *ecdsa.PublicKey + enrollToken string // stored separately from the device } type fakeDeviceService struct { devicepb.UnimplementedDeviceTrustServiceServer + autoCreateDevice bool + + // mu guards devices. + // As a rule of thumb we lock entire methods, so we can work with pointers to + // the contents of devices without worry. mu sync.Mutex devices []storedDevice } @@ -48,32 +57,129 @@ func newFakeDeviceService() *fakeDeviceService { return &fakeDeviceService{} } +func (s *fakeDeviceService) CreateDevice(ctx context.Context, req *devicepb.CreateDeviceRequest) (*devicepb.Device, error) { + dev := req.Device + switch { + case dev == nil: + return nil, trace.BadParameter("device required") + case dev.OsType == devicepb.OSType_OS_TYPE_UNSPECIFIED: + return nil, trace.BadParameter("device OS type required") + case dev.AssetTag == "": + return nil, trace.BadParameter("device asset tag required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Do some superficial checks. + // We don't deeply validate devices or check for ID collisions for brevity. + for _, sd := range s.devices { + if sd.pb.OsType == dev.OsType && sd.pb.AssetTag == dev.AssetTag { + return nil, trace.AlreadyExists("device already registered") + } + } + + // Take a copy and ignore most fields, except what we need for testing. + now := timestamppb.Now() + created := &devicepb.Device{ + ApiVersion: "v1", + Id: uuid.NewString(), + OsType: dev.OsType, + AssetTag: dev.AssetTag, + CreateTime: now, + UpdateTime: now, + EnrollStatus: devicepb.DeviceEnrollStatus_DEVICE_ENROLL_STATUS_NOT_ENROLLED, + } + + // Prepare enroll token, if requested. + var enrollToken string + if req.CreateEnrollToken { + enrollToken = uuid.NewString() + } + + // "Store" device. + s.devices = append(s.devices, storedDevice{ + pb: created, + enrollToken: enrollToken, + }) + + resp := created + if enrollToken != "" { + resp = proto.Clone(created).(*devicepb.Device) + resp.EnrollToken = &devicepb.DeviceEnrollToken{ + Token: enrollToken, + } + } + return resp, nil +} + +func (s *fakeDeviceService) FindDevices(ctx context.Context, req *devicepb.FindDevicesRequest) (*devicepb.FindDevicesResponse, error) { + if req.IdOrTag == "" { + return nil, trace.BadParameter("param id_or_tag required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + var devs []*devicepb.Device + for _, sd := range s.devices { + if sd.pb.Id == req.IdOrTag || sd.pb.AssetTag == req.IdOrTag { + devs = append(devs, sd.pb) + } + } + + return &devicepb.FindDevicesResponse{ + Devices: devs, + }, nil +} + // CreateDeviceEnrollToken implements the creation of fake device enrollment // tokens. // -// Only auto-enrollment is supported by the fake. +// ID-based creation requires a previously-created device and stores the new +// token. // -// Neither the device or token are stored, as the fake EnrollDevice doesn't -// verify tokens. +// Auto-enrollment is completely fake, it doesn't require the device to exist. +// Always returns [FakeEnrollmentToken]. func (s *fakeDeviceService) CreateDeviceEnrollToken(ctx context.Context, req *devicepb.CreateDeviceEnrollTokenRequest) (*devicepb.DeviceEnrollToken, error) { if req.DeviceId != "" { - return nil, trace.AccessDenied("device ID token issuance not supported") + return s.createEnrollTokenID(ctx, req.DeviceId) } + + // Auto-enrollment path. if err := validateCollectedData(req.DeviceData); err != nil { return nil, trace.AccessDenied(err.Error()) } return &devicepb.DeviceEnrollToken{ - Token: "fakedeviceenrolltoken", + Token: FakeEnrollmentToken, + }, nil +} + +func (s *fakeDeviceService) createEnrollTokenID(ctx context.Context, deviceID string) (*devicepb.DeviceEnrollToken, error) { + s.mu.Lock() + defer s.mu.Unlock() + + sd, err := s.findDeviceByID(deviceID) + if err != nil { + return nil, err + } + + // Create and store token for posterior verification. + enrollToken := uuid.NewString() + sd.enrollToken = enrollToken + + return &devicepb.DeviceEnrollToken{ + Token: enrollToken, }, nil } // EnrollDevice implements a fake, server-side device enrollment ceremony. // -// As long as all required fields are non-nil and the challenge signature -// matches, the fake server lets any device be enrolled. Unlike a proper -// DeviceTrustService implementation, it's not necessary to call CreateDevice or -// acquire an enrollment token from the server. +// If the service was created using [WithAutoCreateDevice], the device is +// automatically created. The enrollment token must either match +// [FakeEnrollmentToken] or be created via a successful +// [CreateDeviceEnrollToken] call. func (s *fakeDeviceService) EnrollDevice(stream devicepb.DeviceTrustService_EnrollDeviceServer) error { req, err := stream.Recv() if err != nil { @@ -91,6 +197,38 @@ func (s *fakeDeviceService) EnrollDevice(stream devicepb.DeviceTrustService_Enro if err := validateCollectedData(initReq.DeviceData); err != nil { return trace.Wrap(err) } + cd := initReq.DeviceData + + s.mu.Lock() + defer s.mu.Unlock() + + // Find or auto-create device. + sd, err := s.findDeviceByOSTag(cd.OsType, cd.SerialNumber) + switch { + case s.autoCreateDevice && trace.IsNotFound(err): + // Auto-created device. + now := timestamppb.Now() + dev := &devicepb.Device{ + ApiVersion: "v1", + Id: uuid.NewString(), + OsType: cd.OsType, + AssetTag: cd.SerialNumber, + CreateTime: now, + UpdateTime: now, + EnrollStatus: devicepb.DeviceEnrollStatus_DEVICE_ENROLL_STATUS_NOT_ENROLLED, + } + s.devices = append(s.devices, storedDevice{ + pb: dev, + }) + sd = &s.devices[len(s.devices)-1] + case err != nil: + return err + } + + // Spend enrollment token. + if err := s.spendEnrollmentToken(sd, initReq.Token); err != nil { + return err + } // OS-specific enrollment. var cred *devicepb.DeviceCredential @@ -109,37 +247,38 @@ func (s *fakeDeviceService) EnrollDevice(stream devicepb.DeviceTrustService_Enro return trace.Wrap(err) } - // Prepare device. - cd := initReq.DeviceData - now := timestamppb.Now() - dev := &devicepb.Device{ - ApiVersion: "v1", - Id: uuid.NewString(), - OsType: cd.OsType, - AssetTag: cd.SerialNumber, - CreateTime: now, - UpdateTime: now, - EnrollStatus: devicepb.DeviceEnrollStatus_DEVICE_ENROLL_STATUS_ENROLLED, - Credential: cred, - } - s.mu.Lock() - s.devices = append(s.devices, storedDevice{ - pb: dev, - pub: pub, - }) - s.mu.Unlock() + // Save enrollment information. + sd.pb.UpdateTime = timestamppb.Now() + sd.pb.EnrollStatus = devicepb.DeviceEnrollStatus_DEVICE_ENROLL_STATUS_ENROLLED + sd.pb.Credential = cred + sd.pub = pub // Success. err = stream.Send(&devicepb.EnrollDeviceResponse{ Payload: &devicepb.EnrollDeviceResponse_Success{ Success: &devicepb.EnrollDeviceSuccess{ - Device: dev, + Device: sd.pb, }, }, }) return trace.Wrap(err) } +func (s *fakeDeviceService) spendEnrollmentToken(sd *storedDevice, token string) error { + if token == FakeEnrollmentToken { + sd.enrollToken = "" // Clear just in case. + return nil + } + + if sd.enrollToken != token { + return trace.AccessDenied("invalid device enrollment token") + } + + // "Spend" token. + sd.enrollToken = "" + return nil +} + func randomBytes() ([]byte, error) { buf := make([]byte, 32) _, err := rand.Read(buf) @@ -281,7 +420,11 @@ func (s *fakeDeviceService) AuthenticateDevice(stream devicepb.DeviceTrustServic if err := validateCollectedData(initReq.DeviceData); err != nil { return trace.Wrap(err) } - dev, err := s.findMatchingDevice(initReq.DeviceData, initReq.CredentialId) + + s.mu.Lock() + defer s.mu.Unlock() + + dev, err := s.findDeviceByCredential(initReq.DeviceData, initReq.CredentialId) if err != nil { return trace.Wrap(err) } @@ -373,19 +516,36 @@ func authenticateDeviceTPM(stream devicepb.DeviceTrustService_AuthenticateDevice return nil } -func (s *fakeDeviceService) findMatchingDevice(cd *devicepb.DeviceCollectedData, credentialID string) (*storedDevice, error) { - s.mu.Lock() - defer s.mu.Unlock() - for _, stored := range s.devices { - if cd.OsType != stored.pb.OsType || cd.SerialNumber != stored.pb.AssetTag { - continue - } - if stored.pb.Credential.Id != credentialID { - return nil, trace.BadParameter("unknown credential for device") +func (s *fakeDeviceService) findDeviceByID(deviceID string) (*storedDevice, error) { + return s.findDeviceByPredicate(func(sd *storedDevice) bool { + return sd.pb.Id == deviceID + }) +} + +func (s *fakeDeviceService) findDeviceByOSTag(osType devicepb.OSType, assetTag string) (*storedDevice, error) { + return s.findDeviceByPredicate(func(sd *storedDevice) bool { + return sd.pb.OsType == osType && sd.pb.AssetTag == assetTag + }) +} + +func (s *fakeDeviceService) findDeviceByCredential(cd *devicepb.DeviceCollectedData, credentialID string) (*storedDevice, error) { + sd, err := s.findDeviceByOSTag(cd.OsType, cd.SerialNumber) + if err != nil { + return nil, err + } + if sd.pb.Credential.Id != credentialID { + return nil, trace.BadParameter("unknown credential for device") + } + return sd, nil +} + +func (s *fakeDeviceService) findDeviceByPredicate(fn func(*storedDevice) bool) (*storedDevice, error) { + for i, stored := range s.devices { + if fn(&stored) { + return &s.devices[i], nil } - return &stored, nil } - return nil, trace.NotFound("device %v/%q not enrolled", cd.OsType, cd.SerialNumber) + return nil, trace.NotFound("device not found") } func validateCollectedData(cd *devicepb.DeviceCollectedData) error { diff --git a/lib/devicetrust/testenv/testenv.go b/lib/devicetrust/testenv/testenv.go index bdc77c0805674..7d970c5a00327 100644 --- a/lib/devicetrust/testenv/testenv.go +++ b/lib/devicetrust/testenv/testenv.go @@ -28,10 +28,23 @@ import ( "github.com/gravitational/teleport/lib/utils" ) +// Opt is a creation option for [E] +type Opt func(*E) + +// WithAutoCreateDevice instructs EnrollDevice to automatically create the +// requested device, if it wasn't previously registered. +// See also [FakeEnrollmentToken]. +func WithAutoCreateDevice(b bool) Opt { + return func(e *E) { + e.service.autoCreateDevice = b + } +} + // E is an integrated test environment for device trust. type E struct { DevicesClient devicepb.DeviceTrustServiceClient + service *fakeDeviceService closers []func() error } @@ -48,8 +61,8 @@ func (e *E) Close() error { // MustNew creates a new E or panics. // Callers are required to defer e.Close() to release test resources. -func MustNew() *E { - env, err := New() +func MustNew(opts ...Opt) *E { + env, err := New(opts...) if err != nil { panic(err) } @@ -58,8 +71,14 @@ func MustNew() *E { // New creates a new E. // Callers are required to defer e.Close() to release test resources. -func New() (*E, error) { - e := &E{} +func New(opts ...Opt) (*E, error) { + e := &E{ + service: newFakeDeviceService(), + } + + for _, opt := range opts { + opt(e) + } ok := false defer func() { @@ -85,7 +104,7 @@ func New() (*E, error) { }) // Register service. - devicepb.RegisterDeviceTrustServiceServer(s, newFakeDeviceService()) + devicepb.RegisterDeviceTrustServiceServer(s, e.service) // Start. go func() { diff --git a/tool/tctl/common/devices.go b/tool/tctl/common/devices.go index ca9e1d45fce31..06b614bb58619 100644 --- a/tool/tctl/common/devices.go +++ b/tool/tctl/common/devices.go @@ -24,6 +24,7 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" "github.com/gravitational/trace/trail" + log "github.com/sirupsen/logrus" "google.golang.org/protobuf/types/known/timestamppb" devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" @@ -31,6 +32,7 @@ import ( "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/devicetrust" + dtnative "github.com/gravitational/teleport/lib/devicetrust/native" "github.com/gravitational/teleport/lib/service/servicecfg" ) @@ -66,11 +68,11 @@ func (c *DevicesCommand) Initialize(app *kingpin.Application, cfg *servicecfg.Co addCmd := devicesCmd.Command("add", "Register managed devices.") addCmd.Flag("os", "Operating system"). - Required(). EnumVar(&c.add.os, osTypes...) addCmd.Flag("asset-tag", "Inventory identifier for the device (e.g., Mac serial number)"). - Required(). StringVar(&c.add.assetTag) + addCmd.Flag("current-device", "Registers the current device. Overrides --os and --asset-tag."). + BoolVar(&c.add.currentDevice) addCmd.Flag("enroll", "If set, creates a device enrollment token"). BoolVar(&c.add.enroll) addCmd.Flag("enroll-ttl", "Time duration for the enrollment token"). @@ -81,15 +83,21 @@ func (c *DevicesCommand) Initialize(app *kingpin.Application, cfg *servicecfg.Co rmCmd := devicesCmd.Command("rm", "Removes a managed device.") rmCmd.Flag("device-id", "Device identifier").StringVar(&c.rm.deviceID) rmCmd.Flag("asset-tag", "Inventory identifier for the device").StringVar(&c.rm.assetTag) + rmCmd.Flag("current-device", "Removes the current device. Overrides --device-id and --asset-tag."). + BoolVar(&c.rm.currentDevice) enrollCmd := devicesCmd.Command("enroll", "Creates a new device enrollment token.") enrollCmd.Flag("device-id", "Device identifier").StringVar(&c.enroll.deviceID) enrollCmd.Flag("asset-tag", "Inventory identifier for the device").StringVar(&c.enroll.assetTag) + enrollCmd.Flag("current-device", "Enrolls the current device. Overrides --device-id and --asset-tag."). + BoolVar(&c.enroll.currentDevice) enrollCmd.Flag("ttl", "Time duration for the enrollment token").DurationVar(&c.enroll.ttl) lockCmd := devicesCmd.Command("lock", "Locks a device.") lockCmd.Flag("device-id", "Device identifier").StringVar(&c.lock.deviceID) lockCmd.Flag("asset-tag", "Inventory identifier for the device").StringVar(&c.lock.assetTag) + lockCmd.Flag("current-device", "Locks the current device. Overrides --device-id and --asset-tag."). + BoolVar(&c.lock.currentDevice) lockCmd.Flag("message", "Message to display to locked-out users").StringVar(&c.lock.message) lockCmd.Flag("expires", "Time point (RFC3339) when the lock expires").StringVar(&c.lock.expires) lockCmd.Flag("ttl", "Time duration after which the lock expires").DurationVar(&c.lock.ttl) @@ -121,16 +129,36 @@ func (c *DevicesCommand) TryRun(ctx context.Context, selectedCommand string, aut } type deviceAddCommand struct { - os string - assetTag string + canOperateOnCurrentDevice + + os string // string from command line, distinct from inherited osType! enroll bool enrollTTL time.Duration } func (c *deviceAddCommand) Run(ctx context.Context, authClient auth.ClientI) error { - osType, ok := osTypeToEnum[c.os] - if !ok { - return trace.BadParameter("invalid --os: %v", c.os) + if _, err := c.setCurrentDevice(); err != nil { + return trace.Wrap(err) + } + + // Mimic our required flag errors. + if !c.currentDevice { + switch { + case c.os == "" && c.assetTag == "": + return trace.BadParameter("required flags [--os --asset-tag] not provided") + case c.os == "": + return trace.BadParameter("required flag --os not provided") + case c.assetTag == "": + return trace.BadParameter("required flag --asset-tag not provided") + } + } + + if c.os != "" { + var ok bool + c.osType, ok = osTypeToEnum[c.os] + if !ok { + return trace.BadParameter("invalid --os: %v", c.os) + } } var enrollExpireTime *timestamppb.Timestamp @@ -139,7 +167,7 @@ func (c *deviceAddCommand) Run(ctx context.Context, authClient auth.ClientI) err } created, err := authClient.DevicesClient().CreateDevice(ctx, &devicepb.CreateDeviceRequest{ Device: &devicepb.Device{ - OsType: osType, + OsType: c.osType, AssetTag: c.assetTag, }, CreateEnrollToken: c.enroll, @@ -230,10 +258,19 @@ func (c *deviceListCommand) Run(ctx context.Context, authClient auth.ClientI) er } type deviceRemoveCommand struct { - deviceID, assetTag string + canOperateOnCurrentDevice + + deviceID string } func (c *deviceRemoveCommand) Run(ctx context.Context, authClient auth.ClientI) error { + switch ok, err := c.setCurrentDevice(); { + case err != nil: + return trace.Wrap(err) + case ok: + c.deviceID = "" + } + switch { case c.deviceID == "" && c.assetTag == "": return trace.BadParameter("either --device-id or --asset-tag must be set") @@ -260,11 +297,20 @@ func (c *deviceRemoveCommand) Run(ctx context.Context, authClient auth.ClientI) } type deviceEnrollCommand struct { - deviceID, assetTag string - ttl time.Duration + canOperateOnCurrentDevice + + deviceID string + ttl time.Duration } func (c *deviceEnrollCommand) Run(ctx context.Context, authClient auth.ClientI) error { + switch ok, err := c.setCurrentDevice(); { + case err != nil: + return trace.Wrap(err) + case ok: + c.deviceID = "" + } + switch { case c.deviceID == "" && c.assetTag == "": return trace.BadParameter("either --device-id or --asset-tag must be set") @@ -297,13 +343,26 @@ func (c *deviceEnrollCommand) Run(ctx context.Context, authClient auth.ClientI) } type deviceLockCommand struct { - deviceID, assetTag string - message string - expires string - ttl time.Duration + canOperateOnCurrentDevice + + deviceID string + message string + expires string + ttl time.Duration } func (c *deviceLockCommand) Run(ctx context.Context, authClient auth.ClientI) error { + switch ok, err := c.setCurrentDevice(); { + case err != nil: + return trace.Wrap(err) + case ok: + c.deviceID = "" + // Print here, otherwise device information isn't apparent. + // In other command modes the user just wrote the ID or asset tag in the + // command line. + fmt.Printf("Locking device %q.\n", c.assetTag) + } + switch { case c.deviceID == "" && c.assetTag == "": return trace.BadParameter("either --device-id or --asset-tag must be set") @@ -388,3 +447,34 @@ func findDeviceID(ctx context.Context, devices devicepb.DeviceTrustServiceClient return deviceID, assetTag, nil } + +// canOperateOnCurrentDevice marks commands capable of operating against the +// current device. +type canOperateOnCurrentDevice struct { + osType devicepb.OSType + assetTag string + + // currentDevice means osType and assetTag are set according to the current + // device. + currentDevice bool +} + +func (c *canOperateOnCurrentDevice) setCurrentDevice() (bool, error) { + if !c.currentDevice { + return false, nil + } + + cdd, err := dtnative.CollectDeviceData() + if err != nil { + return false, trace.Wrap(err) + } + + c.osType = cdd.OsType + c.assetTag = cdd.SerialNumber + log.Debugf( + "Running device command against current device: %q/%v", + c.assetTag, + devicetrust.FriendlyOSType(c.osType), + ) + return true, nil +} diff --git a/tool/tsh/common/device.go b/tool/tsh/common/device.go index c0e12d0d16013..3fbdf18273c60 100644 --- a/tool/tsh/common/device.go +++ b/tool/tsh/common/device.go @@ -31,9 +31,10 @@ import ( type deviceCommand struct { enroll *deviceEnrollCommand - // collect and keyget are debug commands. - collect *deviceCollectCommand - keyget *deviceKeygetCommand + // collect, assetTag and keyget are debug commands. + collect *deviceCollectCommand + assetTag *deviceAssetTagCommand + keyget *deviceKeygetCommand // activateCredential is a hidden command invoked on an elevated child // process @@ -44,6 +45,7 @@ func newDeviceCommand(app *kingpin.Application) *deviceCommand { root := &deviceCommand{ enroll: &deviceEnrollCommand{}, collect: &deviceCollectCommand{}, + assetTag: &deviceAssetTagCommand{}, keyget: &deviceKeygetCommand{}, activateCredential: &deviceActivateCredentialCommand{}, } @@ -55,12 +57,15 @@ func newDeviceCommand(app *kingpin.Application) *deviceCommand { // "tsh device enroll" command. root.enroll.CmdClause = parentCmd.Command( "enroll", "Enroll this device as a trusted device. Requires Teleport Enterprise.") - root.enroll.Flag("token", "Device enrollment token"). - Required(). - StringVar(&root.enroll.token) + root.enroll.Flag( + "current-device", + "Attempts to register and enroll the current device. Requires device admin privileges."). + BoolVar(&root.enroll.currentDevice) + root.enroll.Flag("token", "Device enrollment token").StringVar(&root.enroll.token) // "tsh device" hidden debug commands. root.collect.CmdClause = parentCmd.Command("collect", "Simulate enroll/authn device data collection").Hidden() + root.assetTag.CmdClause = parentCmd.Command("asset-tag", "Print the detected device asset tag").Hidden() root.keyget.CmdClause = parentCmd.Command("keyget", "Get information about the device key").Hidden() root.activateCredential.CmdClause = parentCmd.Command("tpm-activate-credential", "").Hidden() root.activateCredential.Flag("encrypted-credential", ""). @@ -75,18 +80,24 @@ func newDeviceCommand(app *kingpin.Application) *deviceCommand { type deviceEnrollCommand struct { *kingpin.CmdClause - token string + currentDevice bool + token string } func (c *deviceEnrollCommand) run(cf *CLIConf) error { + if c.token == "" && !c.currentDevice { + // Mimic our required flag error. + // We don't want to suggest --current-device casually. + return trace.BadParameter("required flag --token not provided") + } + teleportClient, err := makeClient(cf) if err != nil { return trace.Wrap(err) } - var dev *devicepb.Device ctx := cf.Context - if err := client.RetryWithRelogin(ctx, teleportClient, func() error { + return trace.Wrap(client.RetryWithRelogin(ctx, teleportClient, func() error { proxyClient, err := teleportClient.ConnectToProxy(ctx) if err != nil { return trace.Wrap(err) @@ -98,19 +109,41 @@ func (c *deviceEnrollCommand) run(cf *CLIConf) error { return trace.Wrap(err) } defer authClient.Close() - devices := authClient.DevicesClient() - dev, err = enroll.RunCeremony(ctx, devices, cf.Debug, c.token) - return trace.Wrap(err) - }); err != nil { + enrollCeremony := enroll.NewCeremony() + + // Admin fast-tracked enrollment. + if c.currentDevice { + dev, outcome, err := enrollCeremony.RunAdmin(ctx, devices, cf.Debug) + printEnrollOutcome(outcome, dev) // Report partial successes. + return trace.Wrap(err) + } + + // End-user enrollment. + dev, err := enrollCeremony.Run(ctx, devices, cf.Debug, c.token) + if err == nil { + printEnrollOutcome(enroll.DeviceEnrolled, dev) + } return trace.Wrap(err) + })) +} + +func printEnrollOutcome(outcome enroll.RunAdminOutcome, dev *devicepb.Device) { + var action string + switch outcome { + case enroll.DeviceRegisteredAndEnrolled: + action = "registered and enrolled" + case enroll.DeviceRegistered: + action = "registered" + case enroll.DeviceEnrolled: + action = "enrolled" + default: + return // All actions failed, don't print anything. } fmt.Printf( - "Device %q/%v enrolled\n", - dev.AssetTag, devicetrust.FriendlyOSType(dev.OsType), - ) - return nil + "Device %q/%v %v\n", + dev.AssetTag, devicetrust.FriendlyOSType(dev.OsType), action) } type deviceCollectCommand struct { @@ -136,6 +169,20 @@ func (c *deviceCollectCommand) run(cf *CLIConf) error { return nil } +type deviceAssetTagCommand struct { + *kingpin.CmdClause +} + +func (c *deviceAssetTagCommand) run(cf *CLIConf) error { + cdd, err := dtnative.CollectDeviceData() + if err != nil { + return trace.Wrap(err) + } + + fmt.Println(cdd.SerialNumber) + return nil +} + type deviceKeygetCommand struct { *kingpin.CmdClause } diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index f5047ed6ce80d..4d6219ef9f6d5 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1440,6 +1440,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { err = deviceCmd.enroll.run(&cf) case deviceCmd.collect.FullCommand(): err = deviceCmd.collect.run(&cf) + case deviceCmd.assetTag.FullCommand(): + err = deviceCmd.assetTag.run(&cf) case deviceCmd.keyget.FullCommand(): err = deviceCmd.keyget.run(&cf) case deviceCmd.activateCredential.FullCommand():