From 6a9d394e6202011e71955472576244e0becce3f7 Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Tue, 7 Apr 2026 15:23:59 -0500 Subject: [PATCH] Implement clear passcode backend (#43072) **Related issue:** Resolves #42368 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. For the overall story - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually --- changes/39570-clear-passcode | 1 + cmd/fleetctl/fleetctl/mdm_test.go | 8 +- ee/server/service/mdm.go | 82 +++++++ ee/server/service/mdm_test.go | 224 ++++++++++++++++++ pkg/mdm/mdmtest/apple.go | 3 + server/datastore/mysql/apple_mdm.go | 16 +- server/datastore/mysql/apple_mdm_test.go | 52 ++-- server/datastore/mysql/secret_variables.go | 16 ++ .../datastore/mysql/secret_variables_test.go | 33 +++ server/fleet/activities.go | 17 ++ server/fleet/apple_mdm.go | 1 + server/fleet/datastore.go | 2 +- server/fleet/errors.go | 1 + server/fleet/mdm.go | 9 + server/fleet/secrets.go | 4 + server/fleet/service.go | 4 + server/mdm/apple/commander.go | 27 +++ server/mdm/apple/commander_test.go | 50 ++++ server/mdm/nanomdm/service/nanomdm/service.go | 35 ++- server/mock/datastore_mock.go | 4 +- server/mock/service/service_mock.go | 12 + server/service/apple_mdm_test.go | 25 +- server/service/handler.go | 1 + server/service/hosts.go | 7 +- server/service/hosts_test.go | 32 +-- .../service/integration_mdm_commands_test.go | 39 +++ server/service/integration_mdm_test.go | 3 + server/service/mdm.go | 33 ++- server/service/testing_client.go | 31 ++- 29 files changed, 702 insertions(+), 70 deletions(-) create mode 100644 changes/39570-clear-passcode diff --git a/changes/39570-clear-passcode b/changes/39570-clear-passcode new file mode 100644 index 0000000000..a1ed7a5983 --- /dev/null +++ b/changes/39570-clear-passcode @@ -0,0 +1 @@ +* Implemented Clear Passcode feature for iOS and iPadOS. \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/mdm_test.go b/cmd/fleetctl/fleetctl/mdm_test.go index 897619198a..2c43bae220 100644 --- a/cmd/fleetctl/fleetctl/mdm_test.go +++ b/cmd/fleetctl/fleetctl/mdm_test.go @@ -263,8 +263,8 @@ func TestMDMRunCommand(t *testing.T) { } return res, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil @@ -1401,8 +1401,8 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[ ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { return nil, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { h, ok := hostsByID[hostID] diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index e4695b351b..15c1a24774 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1648,3 +1648,85 @@ func (svc *Service) decryptUploadedABMToken(ctx context.Context, token io.Reader } return encryptedToken, decryptedToken, nil } + +func (svc *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) { + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionRead); err != nil { + return nil, err + } + + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "host lite") + } + + if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return nil, err + } + + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get app config") + } + + if fleet.IsApplePlatform(host.Platform) { + return svc.clearPasscodeApple(ctx, host, appCfg) + } + + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "Clearing passcode is only supported on Apple mobile platforms", + }) +} + +func (svc *Service) clearPasscodeApple(ctx context.Context, host *fleet.Host, appCfg *fleet.AppConfig) (*fleet.CommandEnqueueResult, error) { + if !appCfg.MDM.EnabledAndConfigured { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "Apple MDM is not enabled and configured", + }) + } + + if !fleet.IsAppleMobilePlatform(host.Platform) { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "Clearing passcode is only supported on Apple mobile platforms", + }) + } + + mdmData, err := svc.ds.GetHostMDM(ctx, host.ID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host MDM data") + } + + if mdmData.IsPersonalEnrollment { + return nil, &fleet.BadRequestError{ + Message: fleet.CantClearPasscodePersonalHostsMessage, + } + } + + nanoDetails, err := svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get nanomdm enrollment details") + } + + if nanoDetails == nil || nanoDetails.UnlockToken == nil { + return nil, &fleet.BadRequestError{ + Message: fleet.CantClearPasscodePersonalHostsMessage, + } + } + + commandUUID := uuid.NewString() + if err := svc.mdmAppleCommander.ClearPasscode(ctx, []string{host.UUID}, commandUUID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "clearing passcode") + } + + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeClearedPasscode{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating activity for cleared passcode") + } + + return &fleet.CommandEnqueueResult{ + CommandUUID: commandUUID, + RequestType: fleet.AppleMDMCommandTypeClearPasscode, + Platform: host.Platform, + }, nil +} diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index c7b9f8dac0..eed434984c 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto/tls" "errors" "strings" "testing" @@ -9,11 +10,20 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + nanomdm_mdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" "github.com/fleetdm/fleet/v4/server/mock" + mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" + mocksvc "github.com/fleetdm/fleet/v4/server/mock/service" "github.com/fleetdm/fleet/v4/server/ptr" + svcmock "github.com/fleetdm/fleet/v4/server/service/mock" + "github.com/fleetdm/fleet/v4/server/test" "github.com/jmoiron/sqlx" + "github.com/micromdm/nanolib/log/stdlogfmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "howett.net/plist" @@ -218,3 +228,217 @@ func TestCountABMTokensAuth(t *testing.T) { } }) } + +func TestClearPasscode(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + + // Set up the real commander with mocked storage and pusher. + mdmStorage := &mdmmock.MDMAppleStore{} + pushProvider := &svcmock.APNSPushProvider{} + pushProvider.PushFunc = func(_ context.Context, pushes []*nanomdm_mdm.Push) (map[string]*nanomdm_push.Response, error) { + res := make(map[string]*nanomdm_push.Response, len(pushes)) + for _, p := range pushes { + res[p.Token.String()] = &nanomdm_push.Response{Id: "ok"} + } + return res, nil + } + pushFactory := &svcmock.APNSPushProviderFactory{} + pushFactory.NewPushProviderFunc = func(*tls.Certificate) (nanomdm_push.PushProvider, error) { + return pushProvider, nil + } + pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, stdlogfmt.New()) + commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher) + svc := Service{ds: ds, authz: authorizer, mdmAppleCommander: commander, Service: &mocksvc.Service{ + NewActivityFunc: func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + }, + }} + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + + // Common mdmStorage mocks for enqueue + push. + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) { + return nil, nil + } + mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*nanomdm_mdm.Push, error) { + pushes := make(map[string]*nanomdm_mdm.Push, len(targets)) + for _, uuid := range targets { + pushes[uuid] = &nanomdm_mdm.Push{ + PushMagic: "magic" + uuid, + Token: []byte("token" + uuid), + Topic: "topic" + uuid, + } + } + return pushes, nil + } + mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) { + cert, err := tls.LoadX509KeyPair("../../../server/service/testdata/server.pem", "../../../server/service/testdata/server.key") + return &cert, "", err + } + mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { + return false, nil + } + + t.Run("authorization", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, Platform: "ipados"}, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{}, nil + } + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{UnlockToken: new("fake-token")}, nil + } + + cases := []struct { + desc string + user *fleet.User + shoudFailWithAuth bool + }{ + {"no role", test.UserNoRoles, true}, + {"observer", test.UserObserver, true}, + {"observer+", test.UserObserverPlus, true}, + {"technician", test.UserTechnician, true}, + {"gitops", test.UserGitOps, true}, + {"maintainer", test.UserMaintainer, false}, + {"admin", test.UserAdmin, false}, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + ctx := test.UserContext(t.Context(), c.user) + _, err := svc.ClearPasscode(ctx, 1) + checkAuthErr(t, c.shoudFailWithAuth, err) + }) + } + }) + + t.Run("happy path ipados", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, UUID: "host-uuid-1", Platform: "ipados"}, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueCommandFuncInvoked) + mdmStorage.EnqueueCommandFuncInvoked = false + }) + + t.Run("happy path ios", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, UUID: "host-uuid-2", Platform: "ios"}, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueCommandFuncInvoked) + mdmStorage.EnqueueCommandFuncInvoked = false + }) + + t.Run("non-apple platform", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, Platform: "windows"}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.Error(t, err) + var badReq *fleet.BadRequestError + require.ErrorAs(t, err, &badReq) + assert.Contains(t, badReq.Message, "only supported on Apple mobile platforms") + }) + + t.Run("macOS not supported", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, Platform: "darwin"}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.Error(t, err) + var badReq *fleet.BadRequestError + require.ErrorAs(t, err, &badReq) + assert.Contains(t, badReq.Message, "only supported on Apple mobile platforms") + }) + + t.Run("MDM not enabled", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, Platform: "ipados"}, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.Error(t, err) + var badReq *fleet.BadRequestError + require.ErrorAs(t, err, &badReq) + assert.Contains(t, badReq.Message, "Apple MDM is not enabled") + + // Restore for subsequent tests. + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + }) + + t.Run("personal enrollment", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, Platform: "ipados"}, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{IsPersonalEnrollment: true}, nil + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.Error(t, err) + var badReq *fleet.BadRequestError + require.ErrorAs(t, err, &badReq) + assert.Contains(t, badReq.Message, "Unlock token is not available") + }) + + t.Run("enqueue command error", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return &fleet.Host{ID: hostID, UUID: "host-uuid-3", Platform: "ipados"}, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return &fleet.HostMDM{}, nil + } + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) { + return nil, errors.New("enqueue failed") + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "enqueue failed") + + // Restore for subsequent tests. + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) { + return nil, nil + } + }) + + t.Run("host not found", func(t *testing.T) { + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + return nil, ¬FoundError{} + } + + ctx := test.UserContext(t.Context(), test.UserAdmin) + _, err := svc.ClearPasscode(ctx, 999) + require.Error(t, err) + }) +} diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index 0b5ec0c691..6709e8b14a 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -1017,9 +1017,11 @@ func (c *TestAppleMDMClient) Authenticate() error { func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error { pushMagic := "pushmagic" + c.SerialNumber token := []byte("token" + c.SerialNumber) + unlockToken := []byte("unlocktoken" + c.SerialNumber) if c.SerialNumber == "" { pushMagic = "pushmagic" + c.Identifier() token = []byte("token" + c.Identifier()) + unlockToken = []byte("unlocktoken" + c.Identifier()) } payload := map[string]any{ "MessageType": "TokenUpdate", @@ -1028,6 +1030,7 @@ func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error { "NotOnConsole": "false", "PushMagic": pushMagic, "Token": token, + "UnlockToken": unlockToken, } if c.UUID != "" { payload["UDID"] = c.UUID diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index f314f895f3..185b3ede8c 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -7207,31 +7207,27 @@ LIMIT ? return res, nil } -func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - res := []struct { - LastMDMEnrollmentTime *time.Time `db:"authenticate_at"` - LastMDMSeenTime *time.Time `db:"last_seen_at"` - HardwareAttested bool `db:"hardware_attested"` - }{} +func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + res := []*fleet.NanoMDMEnrollmentDetails{} // We are specifically only looking at the singular device enrollment row and not the // potentially many user enrollment rows that will exist for a given device. The device // enrollment row is the only one that gets regularly updated with the last seen time. Along // those same lines authenticate_at gets updated only at the authenticate step during the // enroll process and as such is a good indicator of the last enrollment or reenrollment. query := ` - SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested + SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested, nd.unlock_token FROM nano_devices nd INNER JOIN nano_enrollments ne ON ne.id = nd.id WHERE ne.type IN ('Device', 'User Enrollment (Device)') AND nd.id = ?` err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, query, hostUUID) if err == sql.ErrNoRows || len(res) == 0 { - return nil, nil, false, nil + return &fleet.NanoMDMEnrollmentDetails{}, nil } if err != nil { - return nil, nil, false, ctxerr.Wrap(ctx, err, "get mdm enrollment times") + return nil, ctxerr.Wrap(ctx, err, "get mdm enrollment times") } - return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, res[0].HardwareAttested, nil + return res[0], nil } func (ds *Datastore) AssociateHostMDMIdPAccount(ctx context.Context, hostUUID, idpAcctUUID string) error { diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index d1f7740613..b0bbc9d3e5 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5417,9 +5417,9 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) { `, host.UUID) require.NoError(t, err) - _, _, hardwareAttested, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.True(t, hardwareAttested) + require.True(t, details.HardwareAttested) // host has no boostrap package command yet _, err = ds.GetHostBootstrapPackageCommand(ctx, host.UUID) @@ -5472,9 +5472,9 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) { require.Zero(t, sum.Installed) require.Zero(t, sum.Pending) - _, _, hardwareAttested, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.False(t, hardwareAttested) + require.False(t, details.HardwareAttested) } func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) { @@ -9117,19 +9117,19 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.Nil(t, lastMDMEnrolledAt) - require.Nil(t, lastMDMSeenAt) + require.Nil(t, details.LastMDMEnrollmentTime) + require.Nil(t, details.LastMDMSeenTime) // add user and device enrollment for this device. Timestamps should not be updated so nothing // returned yet nanoEnroll(t, ds, host, true) - lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation - require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value + require.NotNil(t, details.LastMDMEnrollmentTime) // defaults to current time on creation + require.NotNil(t, details.LastMDMSeenTime) // defaults to time 0 value // Add a BYOD host byodHost, err := ds.NewHost(ctx, &fleet.Host{ @@ -9143,10 +9143,10 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnrollUserDevice(t, ds, byodHost) - lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID) + details, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID) require.NoError(t, err) - require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation - require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value + require.NotNil(t, details.LastMDMEnrollmentTime) // defaults to current time on creation + require.NotNil(t, details.LastMDMSeenTime) // defaults to time 0 value authenticateTime := time.Now().Add(-1 * time.Hour).UTC().Round(time.Second) deviceEnrollTime := time.Now().Add(-2 * time.Hour).UTC().Round(time.Second) @@ -9179,19 +9179,19 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) { return nil }) - lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.NotNil(t, lastMDMEnrolledAt) - assert.Equal(t, authenticateTime, *lastMDMEnrolledAt) - require.NotNil(t, lastMDMSeenAt) - assert.Equal(t, deviceEnrollTime, *lastMDMSeenAt) + require.NotNil(t, details) + assert.Equal(t, authenticateTime, *details.LastMDMEnrollmentTime) + require.NotNil(t, details.LastMDMSeenTime) + assert.Equal(t, deviceEnrollTime, *details.LastMDMSeenTime) - lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID) + details, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID) require.NoError(t, err) - require.NotNil(t, lastMDMEnrolledAt) - assert.Equal(t, byodDeviceAuthenticateTime, *lastMDMEnrolledAt) - require.NotNil(t, lastMDMSeenAt) - assert.Equal(t, byodDeviceEnrollTime, *lastMDMSeenAt) + require.NotNil(t, details) + assert.Equal(t, byodDeviceAuthenticateTime, *details.LastMDMEnrollmentTime) + require.NotNil(t, details.LastMDMSeenTime) + assert.Equal(t, byodDeviceEnrollTime, *details.LastMDMSeenTime) } func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) { @@ -9217,10 +9217,10 @@ func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) require.NoError(t, err) - require.Nil(t, lastMDMEnrolledAt) - require.Nil(t, lastMDMSeenAt) + require.Nil(t, details.LastMDMEnrollmentTime) + require.Nil(t, details.LastMDMSeenTime) userEnrollment, err = ds.GetNanoMDMUserEnrollment(ctx, host.UUID) require.NoError(t, err) diff --git a/server/datastore/mysql/secret_variables.go b/server/datastore/mysql/secret_variables.go index 0dcf041d4a..4fd24dd1d6 100644 --- a/server/datastore/mysql/secret_variables.go +++ b/server/datastore/mysql/secret_variables.go @@ -3,6 +3,7 @@ package mysql import ( "context" "database/sql" + "encoding/base64" "encoding/xml" "errors" "fmt" @@ -464,6 +465,21 @@ func (ds *Datastore) ExpandHostSecrets(ctx context.Context, document string, enr return "", ctxerr.Wrapf(ctx, err, "getting pending recovery lock password for host %s", enrollmentID) } secretValues[secretType] = password + case fleet.HostSecretMDMUnlockToken: + details, err := ds.GetNanoMDMEnrollmentDetails(ctx, enrollmentID) + if err != nil { + return "", ctxerr.Wrapf(ctx, err, "getting MDM enrollment details for host %s", enrollmentID) + } + if details == nil { + return "", ctxerr.Errorf(ctx, "no MDM enrollment details found for host %s", enrollmentID) + } + if details.UnlockToken == nil { + return "", ctxerr.Errorf(ctx, "%s", fleet.CantClearPasscodePersonalHostsMessage) + } + + // We need to send base64 encoded data in the field. + encoded := base64.StdEncoding.EncodeToString([]byte(*details.UnlockToken)) + secretValues[secretType] = encoded default: return "", ctxerr.Errorf(ctx, "unknown host secret type: %s", secretType) } diff --git a/server/datastore/mysql/secret_variables_test.go b/server/datastore/mysql/secret_variables_test.go index 85b024a76a..2e98b416a2 100644 --- a/server/datastore/mysql/secret_variables_test.go +++ b/server/datastore/mysql/secret_variables_test.go @@ -1,6 +1,7 @@ package mysql import ( + "encoding/base64" "encoding/json" "sort" "testing" @@ -8,6 +9,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -322,6 +324,37 @@ func testExpandHostSecrets(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.Equal(t, `Password: Pass&wordspecial"chars'`, expandedNonXML) }) + + t.Run("mdm unlock token expansion", func(t *testing.T) { + // Create a host with an MDM unlock token + hostMDM, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("host-mdm-unlock-token-test"), + NodeKey: ptr.String("host-mdm-unlock-token-test-key"), + UUID: "host-mdm-unlock-token-test-uuid", + Hostname: "host-mdm-unlock-token-test-hostname", + Platform: "ios", + }) + require.NoError(t, err) + + unlockToken := "TEST-MDM-UNLOCK-TOKEN" // nolint:gosec // G101: this is a constant identifier, not a credential + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO nano_devices (id, unlock_token, authenticate, platform) VALUES (?, ?, 'fake-auth', 'ios')`, hostMDM.UUID, unlockToken) + require.NoError(t, err) + _, err = q.ExecContext(ctx, `INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, last_seen_at) VALUES (?, ?, 'Device', 'fake-topic', 'fake-push-magic', 'fake-token-hex', NOW())`, hostMDM.UUID, hostMDM.UUID) + return err + }) + + b64Encoded := base64.StdEncoding.EncodeToString([]byte(unlockToken)) + doc := `$FLEET_HOST_SECRET_MDM_UNLOCK_TOKEN` + expected := `` + b64Encoded + `` + expanded, err := ds.ExpandHostSecrets(ctx, doc, hostMDM.UUID) + require.NoError(t, err) + assert.Equal(t, expected, expanded) + }) } func testCreateSecretVariable(t *testing.T, ds *Datastore) { diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0643a6fb9a..38a7e62542 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1852,3 +1852,20 @@ func (a ActivityTypeInstalledCertificate) WasFromAutomation() bool { func (a ActivityTypeInstalledCertificate) HostOnly() bool { return true } + +type ActivityTypeClearedPasscode struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeClearedPasscode) ActivityName() string { + return "cleared_passcode" +} + +func (a ActivityTypeClearedPasscode) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeClearedPasscode) HostOnly() bool { + return true +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 2fe4aa19cc..a252ae488d 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -40,6 +40,7 @@ type MDMAppleCommandIssuer interface { DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error + ClearPasscode(ctx context.Context, hostUUID []string, cmdUUID string) error } // MDMAppleEnrollmentType is the type for Apple MDM enrollments. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index a1baede661..fa9cad9112 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1469,7 +1469,7 @@ type Datastore interface { // GetNanoMDMEnrollmentDetails returns the time of the most recent enrollment, the most recent // MDM protocol seen time, and whether the enrollment is hardware attested for the host with the given UUID - GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) + GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*NanoMDMEnrollmentDetails, error) // IncreasePolicyAutomationIteration marks the policy to fire automation again. IncreasePolicyAutomationIteration(ctx context.Context, policyID uint) error diff --git a/server/fleet/errors.go b/server/fleet/errors.go index aece2741b0..e2aae7f39c 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -31,6 +31,7 @@ var ( CantTurnOffMDMForPersonalHostsMessage = "Couldn't turn off MDM. This command isn't available for personal hosts." CantWipePersonalHostsMessage = "Couldn't wipe. This command isn't available for personal hosts." CantLockPersonalHostsMessage = "Couldn't lock. This command isn't available for personal hosts." + CantClearPasscodePersonalHostsMessage = "Unlock token is not available for this device. Unable to issue ClearPasscode command." CantLockManualIOSIpadOSHostsMessage = "Couldn't lock. This command isn't available for manually enrolled iOS/iPadOS hosts." CantDisableDiskEncryptionIfPINRequiredErrMsg = "Couldn't disable disk encryption, you need to disable the BitLocker PIN requirement first." CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first." diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 6fc324b5d0..880dbfac85 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -36,6 +36,8 @@ const ( // We wrap the key in braces to make Redis hash the keys to the same slot, avoiding CrossSlot errors. MDMProfileProcessingKeyPrefix = "{mdm_profile_processing}" // + :hostUUID MDMProfileProcessingTTL = 1 * time.Minute // We use a low time here, to avoid letting it sit for too long in case of errors. + + AppleMDMCommandTypeClearPasscode = "ClearPasscode" ) // FleetVarName represents the name of a Fleet variable (without the FLEET_VAR_ prefix). @@ -1285,3 +1287,10 @@ type HostMDMIdentifiers struct { Platform string `db:"platform"` TeamID *uint `db:"team_id"` } + +type NanoMDMEnrollmentDetails struct { + LastMDMEnrollmentTime *time.Time `db:"authenticate_at"` + LastMDMSeenTime *time.Time `db:"last_seen_at"` + HardwareAttested bool `db:"hardware_attested"` + UnlockToken *string `db:"unlock_token"` +} diff --git a/server/fleet/secrets.go b/server/fleet/secrets.go index 14e5a0d363..31c4a987e6 100644 --- a/server/fleet/secrets.go +++ b/server/fleet/secrets.go @@ -25,6 +25,10 @@ const ( // during password rotation. The pending password is stored encrypted in host_recovery_key_passwords // (pending_encrypted_password column) and injected as the NewPassword during rotation. HostSecretRecoveryLockPendingPassword = "RECOVERY_LOCK_PENDING_PASSWORD" + + // HostSecretMDMUnlockToken is the host secret type for MDM unlock tokens. + // The token is stored in the nano_devices table and injected at delivery time for ClearPasscode commands sent to Apple MDM-enrolled hosts. + HostSecretMDMUnlockToken = "MDM_UNLOCK_TOKEN" // nolint:gosec // G101: this is a constant identifier, not a credential ) type MissingSecretsError struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 500209e01f..6ea45b5192 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1333,6 +1333,10 @@ type Service interface { UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) WipeHost(ctx context.Context, hostID uint, metadata *MDMWipeMetadata) error + // ClearPasscode is a method that clears the passcode on a host, primarily mobile devices. + // Not script based, only MDM based. + ClearPasscode(ctx context.Context, hostID uint) (*CommandEnqueueResult, error) + // RotateRecoveryLockPassword rotates the recovery lock password for a macOS host. // This is only available for Apple Silicon Macs that are MDM-enrolled and have // an existing recovery lock password. diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index d6b821b43e..5cebd42230 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -492,6 +492,33 @@ func (svc *MDMAppleCommander) DeviceLocation(ctx context.Context, hostUUIDs []st return svc.EnqueueCommand(ctx, hostUUIDs, raw) } +func (svc *MDMAppleCommander) ClearPasscode(ctx context.Context, hostUUIDs []string, cmdUUID string) error { + raw := fmt.Sprintf(` + + + + Command + + RequestType + ClearPasscode + UnlockToken + %s + + CommandUUID + %s + +`, "$"+fleet.HostSecretPrefix+fleet.HostSecretMDMUnlockToken, cmdUUID) + + // We skip EnqueueCommand here, to avoid decoding the command as is binary, which fails to decode with placeholder. + cmd := &mdm.Command{ + CommandUUID: cmdUUID, + Raw: []byte(raw), + } + cmd.Command.RequestType = fleet.AppleMDMCommandTypeClearPasscode + + return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone) +} + // EnqueueCommand takes care of enqueuing the commands and sending push // notifications to the devices. // diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 28bb32f4a6..67ed513d24 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -623,3 +623,53 @@ func TestMDMAppleCommanderSetRecoveryLock(t *testing.T) { require.True(t, mdmStorage.EnqueueCommandFuncInvoked) require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) } + +func TestMDMAppleCommanderClearPasscode(t *testing.T) { + ctx := context.Background() + mdmStorage := &mdmmock.MDMAppleStore{} + pushFactory, _ := newMockAPNSPushProviderFactory() + pusher := nanomdm_pushsvc.New( + mdmStorage, + mdmStorage, + pushFactory, + stdlogfmt.New(), + ) + cmdr := NewMDMAppleCommander(mdmStorage, pusher) + + hostUUIDs := []string{"host-uuid-1"} + cmdUUID := uuid.New().String() + mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) { + require.NotNil(t, cmd) + require.Equal(t, "ClearPasscode", cmd.Command.Command.RequestType) + require.Contains(t, string(cmd.Raw), "$"+fleet.HostSecretPrefix+fleet.HostSecretMDMUnlockToken, "Clear passcode should not use direct unlock token but rather Host secret") + require.Contains(t, string(cmd.Raw), cmdUUID) + return nil, nil + } + + mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targetUUIDs []string) (map[string]*mdm.Push, error) { + require.ElementsMatch(t, hostUUIDs, targetUUIDs) + pushes := make(map[string]*mdm.Push, len(targetUUIDs)) + for _, uuid := range targetUUIDs { + pushes[uuid] = &mdm.Push{ + PushMagic: "magic" + uuid, + Token: []byte("token" + uuid), + Topic: "topic" + uuid, + } + } + return pushes, nil + } + + mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) { + cert, err := tls.LoadX509KeyPair("../../service/testdata/server.pem", "../../service/testdata/server.key") + return &cert, "", err + } + + mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { + return false, nil + } + + err := cmdr.ClearPasscode(ctx, hostUUIDs, cmdUUID) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueCommandFuncInvoked) + require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) +} diff --git a/server/mdm/nanomdm/service/nanomdm/service.go b/server/mdm/nanomdm/service/nanomdm/service.go index 4a0155eb36..a70ba05ff5 100644 --- a/server/mdm/nanomdm/service/nanomdm/service.go +++ b/server/mdm/nanomdm/service/nanomdm/service.go @@ -286,11 +286,9 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe cmd.Raw = []byte(expanded) } - // Expand host-scoped secrets for SetRecoveryLock commands. - // SetRecoveryLock is device-only, so UDID is always present and matches host UUID. - if cmd.Command.Command.RequestType == fleet.SetRecoveryLockCmdName { + expandHostSecrets := func(cmdRaw string, onError func(hostUUID string, errorMsg string)) (expanded string, didError bool) { hostUUID := results.UDID - hostExpanded, err := s.store.ExpandHostSecrets(r.Context, string(cmd.Raw), hostUUID) + hostExpanded, err := s.store.ExpandHostSecrets(r.Context, cmdRaw, hostUUID) if err != nil { errorMsg := fmt.Sprintf("failed to expand host secrets: %v", err) logger.Info("level", "error", "msg", "expanding host secrets", "err", err) @@ -308,12 +306,41 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe if storeErr := s.store.StoreCommandReport(r, failedResult); storeErr != nil { logger.Info("level", "error", "msg", "storing failed command result", "err", storeErr) } + + onError(hostUUID, errorMsg) + + return "", true + } + + return hostExpanded, false + } + + // Expand host-scoped secrets for SetRecoveryLock commands. + // SetRecoveryLock is device-only, so UDID is always present and matches host UUID. + if cmd.Command.Command.RequestType == fleet.SetRecoveryLockCmdName { + hostExpanded, didError := expandHostSecrets(string(cmd.Raw), func(hostUUID string, errorMsg string) { // Mark the host's recovery lock status as failed so it's not stuck in pending. if storeErr := s.store.SetRecoveryLockFailed(r.Context, hostUUID, errorMsg); storeErr != nil { logger.Info("level", "error", "msg", "setting recovery lock failed", "err", storeErr) } + }) + if didError { return nil, nil } + + cmd.Raw = []byte(hostExpanded) + } + + // Expand host-scoped secrets for ClearPasscode commands. + if cmd.Command.Command.RequestType == fleet.AppleMDMCommandTypeClearPasscode { + hostExpanded, didError := expandHostSecrets(string(cmd.Raw), func(hostUUID string, errorMsg string) { + // For ClearPasscode command, if we fail to expand host secrets, it likely means the unlock token is missing or invalid. + logger.Info("level", "error", "msg", "failed to expand host secrets for ClearPasscode command", "host_uuid", hostUUID, "err", errorMsg) + }) + if didError { + return nil, nil + } + cmd.Raw = []byte(hostExpanded) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index eec453ea24..76e17776f5 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1007,7 +1007,7 @@ type GetNanoMDMUserEnrollmentUsernameAndUUIDFunc func(ctx context.Context, devic type UpdateNanoMDMUserEnrollmentUsernameFunc func(ctx context.Context, deviceID string, userUUID string, username string) error -type GetNanoMDMEnrollmentDetailsFunc func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) +type GetNanoMDMEnrollmentDetailsFunc func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) type IncreasePolicyAutomationIterationFunc func(ctx context.Context, policyID uint) error @@ -8056,7 +8056,7 @@ func (s *DataStore) UpdateNanoMDMUserEnrollmentUsername(ctx context.Context, dev return s.UpdateNanoMDMUserEnrollmentUsernameFunc(ctx, deviceID, userUUID, username) } -func (s *DataStore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { +func (s *DataStore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { s.mu.Lock() s.GetNanoMDMEnrollmentDetailsFuncInvoked = true s.mu.Unlock() diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 431e873799..5528fc9b7e 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -812,6 +812,8 @@ type UnlockHostFunc func(ctx context.Context, hostID uint) (unlockPIN string, er type WipeHostFunc func(ctx context.Context, hostID uint, metadata *fleet.MDMWipeMetadata) error +type ClearPasscodeFunc func(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) + type RotateRecoveryLockPasswordFunc func(ctx context.Context, hostID uint) error type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) @@ -2095,6 +2097,9 @@ type Service struct { WipeHostFunc WipeHostFunc WipeHostFuncInvoked bool + ClearPasscodeFunc ClearPasscodeFunc + ClearPasscodeFuncInvoked bool + RotateRecoveryLockPasswordFunc RotateRecoveryLockPasswordFunc RotateRecoveryLockPasswordFuncInvoked bool @@ -5011,6 +5016,13 @@ func (s *Service) WipeHost(ctx context.Context, hostID uint, metadata *fleet.MDM return s.WipeHostFunc(ctx, hostID, metadata) } +func (s *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) { + s.mu.Lock() + s.ClearPasscodeFuncInvoked = true + s.mu.Unlock() + return s.ClearPasscodeFunc(ctx, hostID) +} + func (s *Service) RotateRecoveryLockPassword(ctx context.Context, hostID uint) error { s.mu.Lock() s.RotateRecoveryLockPasswordFuncInvoked = true diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 741f490713..fc6c43aa9a 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -199,8 +199,12 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) { return &fleet.NanoEnrollment{Enabled: false}, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{ + LastMDMEnrollmentTime: nil, + LastMDMSeenTime: nil, + HardwareAttested: false, + }, nil } ds.GetMDMAppleCommandRequestTypeFunc = func(ctx context.Context, commandUUID string) (string, error) { return "", nil @@ -451,6 +455,23 @@ func TestAppleMDMAuthorization(t *testing.T) { _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}) require.Error(t, err) require.ErrorContains(t, err, fleet.ErrMissingLicense.Error()) + + rawB64PremiumCmd = base64.RawStdEncoding.EncodeToString(fmt.Appendf([]byte{}, ` + + + + Command + + RequestType + %s + + CommandUUID + uuid + +`, "ClearPasscode")) + _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}) + require.Error(t, err) + require.ErrorContains(t, err, fleet.ErrMissingLicense.Error()) }) cmdUUIDToHostUUIDs := map[string][]string{ diff --git a/server/service/handler.go b/server/service/handler.go index 5bd249af77..1ac1bf2ec4 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -567,6 +567,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/lock", lockHostEndpoint, lockHostRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/unlock", unlockHostEndpoint, unlockHostRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/wipe", wipeHostEndpoint, wipeHostRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/clear_passcode", clearPasscodeEndpoint, clearPasscodeRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/recovery_lock_password/rotate", rotateRecoveryLockPasswordEndpoint, rotateRecoveryLockPasswordRequest{}) // Generative AI diff --git a/server/service/hosts.go b/server/service/hosts.go index cb73fef7c3..fe3cbc0612 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1788,7 +1788,12 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f // fetch host last seen at and last enrolled at times, currently only supported for // Apple platforms - mdmLastEnrollment, mdmLastCheckedIn, mdmHardwareAttested, err = svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + details, err := svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID) + if details != nil { + mdmLastCheckedIn = details.LastMDMSeenTime + mdmLastEnrollment = details.LastMDMEnrollmentTime + mdmHardwareAttested = details.HardwareAttested + } if err != nil { return nil, ctxerr.Wrap(ctx, err, "get host mdm enrollment times") } diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index cf267156ca..15fbf458d3 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -152,8 +152,8 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil @@ -465,8 +465,12 @@ func TestHostDetailsMDMTimestamps(t *testing.T) { ts1 := time.Now().Add(-1 * time.Hour).UTC() ts2 := time.Now().Add(-2 * time.Hour).UTC() - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return &ts1, &ts2, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{ + LastMDMEnrollmentTime: &ts1, + LastMDMSeenTime: &ts2, + HardwareAttested: false, + }, nil } cases := []struct { @@ -556,8 +560,8 @@ func TestHostDetailsOSSettings(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil @@ -798,8 +802,8 @@ func TestHostDetailsRecoveryLockPasswordStatus(t *testing.T) { ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) { return &fleet.HostDiskEncryptionKey{}, nil @@ -2865,8 +2869,8 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.UpdateHostIssuesFailingPoliciesFunc = func(ctx context.Context, hostIDs []uint) error { return nil @@ -3009,8 +3013,8 @@ func TestHostMDMProfileScopes(t *testing.T) { ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } ds.UpdateHostIssuesFailingPoliciesFunc = func(ctx context.Context, hostIDs []uint) error { return nil @@ -3204,8 +3208,8 @@ func TestLockUnlockWipeHostAuth(t *testing.T) { ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { return true, nil } - ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) { - return nil, nil, false, nil + ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) { + return &fleet.NanoMDMEnrollmentDetails{}, nil } cases := []struct { diff --git a/server/service/integration_mdm_commands_test.go b/server/service/integration_mdm_commands_test.go index 13cf7b1e4c..4a347346de 100644 --- a/server/service/integration_mdm_commands_test.go +++ b/server/service/integration_mdm_commands_test.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/xml" "fmt" @@ -760,3 +761,41 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() { }) } } + +func (s *integrationMDMTestSuite) TestClearPasscodeCommand() { + t := s.T() + + s.enableABM(t.Name()) + + // Create iOS host and enroll in MDM + iosHost, iosMDMClient := s.createAppleMobileHostThenDEPEnrollMDM("ios", mdmtest.RandSerialNumber()) + + // Trigger ClearPasscode endpoint + var clearPasscodeResp clearPasscodeResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/clear_passcode", iosHost.ID), nil, http.StatusOK, &clearPasscodeResp) + require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, clearPasscodeResp.RequestType) + require.Equal(t, "ios", clearPasscodeResp.Platform) + + s.lastHostActivityMatches(iosHost.ID, fleet.ActivityTypeClearedPasscode{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, iosHost.ID, iosHost.DisplayName()), 0) + + // Check in with the iOS device to receive the ClearPasscode command + cmd, err := iosMDMClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, cmd.Command.RequestType) + b64Encoded := base64.StdEncoding.EncodeToString([]byte("unlocktoken" + iosMDMClient.SerialNumber)) + require.Contains(t, string(cmd.Raw), b64Encoded) + + // Acknowledge the ClearPasscode command + _, err = iosMDMClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Fetch the command result and check the response is acknowledged (+ Payload has the expected unlock token value) + commandResultResp := &getMDMCommandResultsResponse{} + s.DoJSON("GET", "/api/latest/fleet/commands/results", &getMDMCommandResultsRequest{ + CommandUUID: clearPasscodeResp.CommandUUID, + }, http.StatusOK, commandResultResp) + require.Len(t, commandResultResp.Results, 1) + require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, commandResultResp.Results[0].RequestType) + require.NotNil(t, commandResultResp.Results[0].Result) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 9bce216c98..50669eb790 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -16108,6 +16108,9 @@ func (s *integrationMDMTestSuite) TestAppleMDMActionsOnPersonalHost() { r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusBadRequest) require.Contains(t, extractServerErrorText(r.Body), fleet.CantWipePersonalHostsMessage) + r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/clear_passcode", host.ID), nil, http.StatusBadRequest) + require.Contains(t, extractServerErrorText(r.Body), fleet.CantClearPasscodePersonalHostsMessage) + // Confirm that turning off MDM for personal hosts are allowed - NEEDS to be last, to not turn off MDM for the other checks. s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID), nil, http.StatusNoContent) } diff --git a/server/service/mdm.go b/server/service/mdm.go index b3f798f986..c41b27935e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -644,8 +644,9 @@ func (svc *Service) validateAppleMDMCommand(ctx context.Context, rawXMLCmd []byt } var appleMDMPremiumCommands = map[string]bool{ - "EraseDevice": true, - "DeviceLock": true, + "EraseDevice": true, + "DeviceLock": true, + "ClearPasscode": true, } func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte, deviceIDs []string) (result *fleet.CommandEnqueueResult, err error) { @@ -3707,3 +3708,31 @@ func (svc *Service) UnenrollMDM(ctx context.Context, hostID uint) error { } return nil } + +type clearPasscodeRequest struct { + HostID uint `url:"id"` +} + +type clearPasscodeResponse struct { + *fleet.CommandEnqueueResult + Err error `json:"error,omitempty"` +} + +func (r clearPasscodeResponse) Error() error { return r.Err } + +func clearPasscodeEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*clearPasscodeRequest) + res, err := svc.ClearPasscode(ctx, req.HostID) + if err != nil { + return clearPasscodeResponse{Err: err}, nil + } + return clearPasscodeResponse{CommandEnqueueResult: res}, nil +} + +func (svc *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 0a21ca19c0..6796890fd2 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -39,10 +39,12 @@ import ( ) // testSAMLIDPBaseURL is the SAML IDP base URL, read from FLEET_SAML_IDP_HTTP_PORT (defaults to http://localhost:9080). -var testSAMLIDPBaseURL = getTestSAMLIDPBaseURL() -var testSAMLIDPMetadataURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/metadata.php" -var testSAMLIDPSSOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SSOService.php" -var testSAMLIDPSLOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SingleLogoutService.php" +var ( + testSAMLIDPBaseURL = getTestSAMLIDPBaseURL() + testSAMLIDPMetadataURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/metadata.php" + testSAMLIDPSSOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SSOService.php" + testSAMLIDPSLOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SingleLogoutService.php" +) func getTestSAMLIDPBaseURL() string { if port := os.Getenv("FLEET_SAML_IDP_HTTP_PORT"); port != "" { @@ -643,6 +645,27 @@ func (ts *withServer) lastActivityMatchesExtended(name, details string, id uint, return act.ID } +func (ts *withServer) lastHostActivityMatches(hostID uint, name, details string, id uint) uint { + t := ts.s.T() + var listActivities listActivitiesResponse + ts.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", hostID), nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10") + require.NotEmpty(t, listActivities.Activities) + + act := listActivities.Activities[0] + + if name != "" { + assert.Equal(t, name, act.Type) + } + if details != "" { + require.NotNil(t, act.Details) + assert.JSONEq(t, details, string(*act.Details)) + } + if id > 0 { + assert.Equal(t, id, act.ID) + } + return act.ID +} + // gets the latest activity with the specified type name and checks that it // matches any provided properties. empty string or 0 id means do not check // that property. It returns the ID of that latest activity.