Set recovery lock password - mdm commands (#41217)

This commit is contained in:
Tim Lee
2026-03-12 06:06:56 -06:00
committed by GitHub
parent c7eeb82b49
commit 8b43190f5d
27 changed files with 1650 additions and 2 deletions
+24
View File
@@ -1987,3 +1987,27 @@ func cronMigrateToPerHostPolicy(
)
return s, nil
}
func newRecoveryLockPasswordSchedule(
ctx context.Context,
instanceID string,
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger *slog.Logger,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronSendRecoveryLockCommands)
defaultInterval = 5 * time.Minute
)
logger = logger.With("cron", name)
s := schedule.New(
ctx, name, instanceID, defaultInterval, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("send_recovery_lock_commands", func(ctx context.Context) error {
return apple_mdm.SendRecoveryLockCommands(ctx, ds, commander, logger)
}),
)
return s, nil
}
+8
View File
@@ -1291,6 +1291,13 @@ the way that the Fleet server works.
}); err != nil {
initFatal(err, "failed to register refresh vpp app versions schedule")
}
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger)
}); err != nil {
initFatal(err, "failed to register recovery lock password schedule")
}
}
if license.IsPremium() && config.Activity.EnableAuditLog {
@@ -1468,6 +1475,7 @@ the way that the Fleet server works.
mdmCheckinAndCommandService.RegisterResultsHandler("InstalledApplicationList", service.NewInstalledApplicationListResultsHandler(ds, commander, logger, config.Server.VPPVerifyTimeout, config.Server.VPPVerifyRequestDelay, svc.NewActivity))
mdmCheckinAndCommandService.RegisterResultsHandler(fleet.DeviceLocationCmdName, service.NewDeviceLocationResultsHandler(ds, commander, logger))
mdmCheckinAndCommandService.RegisterResultsHandler(fleet.SetRecoveryLockCmdName, service.NewSetRecoveryLockResultsHandler(ds, logger))
hasSCEPChallenge, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge})
if err != nil {
+167
View File
@@ -7239,3 +7239,170 @@ func (ds *Datastore) DeleteHostLocationData(ctx context.Context, hostID uint) er
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID)
return ctxerr.Wrap(ctx, err, "delete host location data")
}
///////////////////////////////////////////////////////////////////////////////
// Apple MDM Recovery Lock Password
func (ds *Datastore) SetHostsRecoveryLockPasswords(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
if len(passwords) == 0 {
return nil
}
// Build values for bulk insert.
// Status is set to 'pending' immediately to prevent the host from being picked up
// again by the next cron run while the command is being enqueued. If enqueue fails,
// ClearRecoveryLockPendingStatus should be called to reset the status to NULL.
var args []any
for _, p := range passwords {
encrypted, err := encrypt([]byte(p.Password), ds.serverPrivateKey)
if err != nil {
return ctxerr.Wrap(ctx, err, "encrypting recovery lock password")
}
args = append(args, p.HostUUID, encrypted, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall)
}
stmt := `
INSERT INTO host_recovery_key_passwords (host_uuid, encrypted_password, status, operation_type)
VALUES %s
ON DUPLICATE KEY UPDATE
encrypted_password = VALUES(encrypted_password),
status = VALUES(status),
operation_type = VALUES(operation_type),
error_message = NULL,
deleted = 0
`
placeholders := strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?),", len(passwords)), ",")
stmt = fmt.Sprintf(stmt, placeholders)
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "storing recovery lock passwords")
}
return nil
}
func (ds *Datastore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
const stmt = `SELECT encrypted_password, updated_at FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`
var row struct {
EncryptedPassword []byte `db:"encrypted_password"`
UpdatedAt time.Time `db:"updated_at"`
}
if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostUUID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
}
return nil, ctxerr.Wrap(ctx, err, "getting recovery lock password")
}
decrypted, err := decrypt(row.EncryptedPassword, ds.serverPrivateKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "decrypting recovery lock password")
}
return &fleet.HostRecoveryLockPassword{
Password: string(decrypted),
UpdatedAt: row.UpdatedAt,
}, nil
}
func (ds *Datastore) GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error) {
// Query hosts that:
// - Have enable_recovery_lock_password = true (from team config or appconfig for no-team hosts)
// - Are Apple Silicon (ARM CPU)
// - Are MDM enrolled (enabled = 1 and device enrollment type)
// - Have no recovery lock password record OR have a password with NULL status (command not yet enqueued)
// Note: hosts with status pending, verified, or failed are NOT included
const stmt = `
SELECT h.uuid
FROM hosts h
JOIN nano_enrollments ne ON ne.device_id = h.uuid
JOIN host_mdm hm ON hm.host_id = h.id
LEFT JOIN teams t ON t.id = h.team_id
CROSS JOIN app_config_json ac
LEFT JOIN host_recovery_key_passwords rkp ON rkp.host_uuid = h.uuid AND rkp.deleted = 0
WHERE h.platform = 'darwin'
AND h.cpu_type LIKE '%arm%'
AND ne.enabled = 1
AND ne.type IN ('Device', 'User Enrollment (Device)')
AND hm.enrolled = 1
AND (
-- Team hosts: check team config
(h.team_id IS NOT NULL AND JSON_EXTRACT(t.config, '$.mdm.enable_recovery_lock_password') = true)
OR
-- No-team hosts: check appconfig
(h.team_id IS NULL AND JSON_EXTRACT(ac.json_value, '$.mdm.enable_recovery_lock_password') = true)
)
AND (rkp.host_uuid IS NULL OR rkp.status IS NULL)
LIMIT 500
`
var hostUUIDs []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostUUIDs, stmt); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get hosts for recovery lock action")
}
return hostUUIDs, nil
}
func (ds *Datastore) SetRecoveryLockVerified(ctx context.Context, hostUUID string) error {
stmt := fmt.Sprintf(`
UPDATE host_recovery_key_passwords
SET status = '%s',
error_message = NULL
WHERE host_uuid = ?
AND deleted = 0
`, fleet.MDMDeliveryVerified)
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "set recovery lock verified")
}
return nil
}
func (ds *Datastore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
stmt := fmt.Sprintf(`
UPDATE host_recovery_key_passwords
SET status = '%s',
error_message = ?
WHERE host_uuid = ?
AND deleted = 0
`, fleet.MDMDeliveryFailed)
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, errorMsg, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "set recovery lock failed")
}
return nil
}
func (ds *Datastore) ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error {
if len(hostUUIDs) == 0 {
return nil
}
// Reset status to NULL for hosts that failed to have their commands enqueued.
// This allows them to be picked up again on the next cron run.
// Only clears status if it's currently 'pending' to avoid overwriting other statuses.
stmt := fmt.Sprintf(`
UPDATE host_recovery_key_passwords
SET status = NULL
WHERE host_uuid IN (?)
AND status = '%s'
AND deleted = 0
`, fleet.MDMDeliveryPending)
query, args, err := sqlx.In(stmt, hostUUIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build query for clear recovery lock pending status")
}
if _, err := ds.writer(ctx).ExecContext(ctx, query, args...); err != nil {
return ctxerr.Wrap(ctx, err, "clear recovery lock pending status")
}
return nil
}
+372
View File
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"testing"
@@ -112,6 +113,13 @@ func TestMDMApple(t *testing.T) {
{"DeviceLocation", testDeviceLocation},
{"TestGetDEPAssignProfileExpiredCooldowns", testGetDEPAssignProfileExpiredCooldowns},
{"DeleteMDMAppleDeclarationByNameCancelsInstalls", testDeleteMDMAppleDeclarationByNameCancelsInstalls},
{"RecoveryLockPasswordSetAndGet", testRecoveryLockPasswordSetAndGet},
{"RecoveryLockPasswordBulkSet", testRecoveryLockPasswordBulkSet},
{"RecoveryLockPasswordGetNotFound", testRecoveryLockPasswordGetNotFound},
{"RecoveryLockPasswordSetOverwrite", testRecoveryLockPasswordSetOverwrite},
{"RecoveryLockPasswordUpdatedAtChanges", testRecoveryLockPasswordUpdatedAtChanges},
{"RecoveryLockStatusMethods", testRecoveryLockStatusMethods},
{"GetHostsForRecoveryLockAction", testGetHostsForRecoveryLockAction},
}
for _, c := range cases {
@@ -10024,3 +10032,367 @@ func testDeleteMDMAppleDeclarationByNameCancelsInstalls(t *testing.T, ds *Datast
runTest(t, &team.ID)
})
}
func testRecoveryLockPasswordSetAndGet(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "test-host-1", "1.2.3.4", "h1key", "h1uuid", time.Now())
// Generate and set password
password := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password}})
require.NoError(t, err)
// Get password and verify it matches
result, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
assert.Equal(t, password, result.Password)
assert.False(t, result.UpdatedAt.IsZero())
}
func testRecoveryLockPasswordBulkSet(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create multiple hosts
host1 := test.NewHost(t, ds, "bulk-host-1", "1.2.3.10", "bulk1key", "bulk1uuid", time.Now())
host2 := test.NewHost(t, ds, "bulk-host-2", "1.2.3.11", "bulk2key", "bulk2uuid", time.Now())
host3 := test.NewHost(t, ds, "bulk-host-3", "1.2.3.12", "bulk3key", "bulk3uuid", time.Now())
// Generate passwords for all hosts
pw1 := apple_mdm.GenerateRecoveryLockPassword()
pw2 := apple_mdm.GenerateRecoveryLockPassword()
pw3 := apple_mdm.GenerateRecoveryLockPassword()
// Bulk set passwords
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
{HostUUID: host1.UUID, Password: pw1},
{HostUUID: host2.UUID, Password: pw2},
{HostUUID: host3.UUID, Password: pw3},
})
require.NoError(t, err)
// Verify all passwords are stored correctly
result1, err := ds.GetHostRecoveryLockPassword(ctx, host1.UUID)
require.NoError(t, err)
assert.Equal(t, pw1, result1.Password)
result2, err := ds.GetHostRecoveryLockPassword(ctx, host2.UUID)
require.NoError(t, err)
assert.Equal(t, pw2, result2.Password)
result3, err := ds.GetHostRecoveryLockPassword(ctx, host3.UUID)
require.NoError(t, err)
assert.Equal(t, pw3, result3.Password)
// Verify all passwords are different
assert.NotEqual(t, pw1, pw2)
assert.NotEqual(t, pw2, pw3)
assert.NotEqual(t, pw1, pw3)
}
func testRecoveryLockPasswordGetNotFound(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Try to get password for non-existent host
_, err := ds.GetHostRecoveryLockPassword(ctx, "non-existent-uuid")
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
}
func testRecoveryLockPasswordSetOverwrite(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "test-host-2", "1.2.3.5", "h2key", "h2uuid", time.Now())
// Set password first time
password1 := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password1}})
require.NoError(t, err)
// Set password second time (should overwrite)
password2 := apple_mdm.GenerateRecoveryLockPassword()
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password2}})
require.NoError(t, err)
// Passwords should be different (randomly generated)
assert.NotEqual(t, password1, password2)
// Verify only the new password is stored
result, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
assert.Equal(t, password2, result.Password)
}
func testRecoveryLockPasswordUpdatedAtChanges(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "test-host-3", "1.2.3.6", "h3key", "h3uuid", time.Now())
// Set password first time
password1 := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password1}})
require.NoError(t, err)
result1, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
// Wait a bit to ensure timestamp changes
time.Sleep(1 * time.Second)
// Set password second time
password2 := apple_mdm.GenerateRecoveryLockPassword()
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password2}})
require.NoError(t, err)
result2, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
// updated_at should have changed
assert.True(t, result2.UpdatedAt.After(result1.UpdatedAt), "updated_at should increase after overwrite")
}
func testRecoveryLockStatusMethods(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Helper to create a host with a recovery lock password (status is set to 'pending' atomically)
setupHost := func(t *testing.T, name, ip, key, uuid string) *fleet.Host {
t.Helper()
host := test.NewHost(t, ds, name, ip, key, uuid, time.Now())
pw := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
require.NoError(t, err)
return host
}
t.Run("SetHostsRecoveryLockPasswords sets pending status atomically", func(t *testing.T) {
host := setupHost(t, "atomic-pending-host", "1.2.3.6", "atomickey", "atomicuuid")
// Verify status is pending immediately after storing password
var status string
err := ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, string(fleet.MDMDeliveryPending), status)
})
t.Run("SetRecoveryLockVerified", func(t *testing.T) {
host := setupHost(t, "verified-host", "1.2.3.9", "verifiedkey", "verifieduuid")
// Set verified status
err := ds.SetRecoveryLockVerified(ctx, host.UUID)
require.NoError(t, err)
// Verify status
var status string
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, string(fleet.MDMDeliveryVerified), status)
})
t.Run("SetRecoveryLockFailed", func(t *testing.T) {
host := setupHost(t, "failed-host", "1.2.3.10", "failedkey", "faileduuid")
// Set failed status
err := ds.SetRecoveryLockFailed(ctx, host.UUID, "test error message")
require.NoError(t, err)
// Verify status and error message
var status, errorMsg string
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, string(fleet.MDMDeliveryFailed), status)
err = ds.writer(ctx).GetContext(ctx, &errorMsg, "SELECT error_message FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, "test error message", errorMsg)
})
t.Run("ClearRecoveryLockPendingStatus", func(t *testing.T) {
host := setupHost(t, "clear-pending-host", "1.2.3.11", "clearkey", "clearuuid")
// Verify status is pending
var status sql.NullString
err := ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, string(fleet.MDMDeliveryPending), status.String)
// Clear pending status
err = ds.ClearRecoveryLockPendingStatus(ctx, []string{host.UUID})
require.NoError(t, err)
// Verify status is now NULL
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.False(t, status.Valid, "status should be NULL after clearing")
})
t.Run("ClearRecoveryLockPendingStatus only clears pending", func(t *testing.T) {
host := setupHost(t, "no-clear-verified-host", "1.2.3.12", "ncvkey", "ncvuuid")
// Set to verified
err := ds.SetRecoveryLockVerified(ctx, host.UUID)
require.NoError(t, err)
// Try to clear - should not affect verified status
err = ds.ClearRecoveryLockPendingStatus(ctx, []string{host.UUID})
require.NoError(t, err)
// Verify status is still verified
var status string
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
require.NoError(t, err)
assert.Equal(t, string(fleet.MDMDeliveryVerified), status)
})
}
func testGetHostsForRecoveryLockAction(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Helper to create a team with recovery lock setting
createTeamWithRecoveryLock := func(name string, enabled bool) *fleet.Team {
team, err := ds.NewTeam(ctx, &fleet.Team{Name: name})
require.NoError(t, err)
team.Config.MDM.EnableRecoveryLockPassword = enabled
team, err = ds.SaveTeam(ctx, team)
require.NoError(t, err)
return team
}
// Helper to set app config recovery lock setting
setAppConfigRecoveryLock := func(enabled bool) {
ac, err := ds.AppConfig(ctx)
require.NoError(t, err)
ac.MDM.EnableRecoveryLockPassword = optjson.SetBool(enabled)
err = ds.SaveAppConfig(ctx, ac)
require.NoError(t, err)
}
// Helper to set host CPU type
setHostCPUType := func(hostID uint, cpuType string) {
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET cpu_type = ? WHERE id = ?`, cpuType, hostID)
require.NoError(t, err)
}
// Initially no eligible hosts
hosts, err := ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.Empty(t, hosts)
// Create eligible Apple Silicon host in team with recovery lock enabled
teamARM := createTeamWithRecoveryLock("team-arm", true)
hostARM := test.NewHost(t, ds, "arm-host", "1.2.5.1", "armkey", "armuuid", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamARM.ID))
setHostCPUType(hostARM.ID, "arm64")
nanoEnrollAndSetHostMDMData(t, ds, hostARM, false)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.True(t, slices.Contains(hosts, hostARM.UUID), "Apple Silicon (ARM) host should be eligible")
// Create ineligible Intel host
teamIntel := createTeamWithRecoveryLock("team-intel", true)
hostIntel := test.NewHost(t, ds, "intel-host", "1.2.5.2", "intelkey", "inteluuid", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamIntel.ID))
setHostCPUType(hostIntel.ID, "x86_64")
nanoEnrollAndSetHostMDMData(t, ds, hostIntel, false)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostIntel.UUID), "Intel host should NOT be eligible")
// Create host in team with recovery lock DISABLED
teamDisabled := createTeamWithRecoveryLock("team-disabled", false)
hostDisabled := test.NewHost(t, ds, "disabled-team-host", "1.2.5.4", "dtkey", "dtuuid", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamDisabled.ID))
setHostCPUType(hostDisabled.ID, "arm64e")
nanoEnrollAndSetHostMDMData(t, ds, hostDisabled, false)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostDisabled.UUID), "host in disabled team should NOT be eligible")
// Create host without MDM enrollment
teamNotEnrolled := createTeamWithRecoveryLock("team-not-enrolled", true)
hostNotEnrolled := test.NewHost(t, ds, "not-enrolled-host", "1.2.5.5", "nekey", "neuuid", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamNotEnrolled.ID))
setHostCPUType(hostNotEnrolled.ID, "arm64e")
// No nano enrollment
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostNotEnrolled.UUID), "non-enrolled host should NOT be eligible")
// Create Windows host (not darwin)
teamNotDarwin := createTeamWithRecoveryLock("team-not-darwin", true)
hostWindows := test.NewHost(t, ds, "windows-host", "1.2.5.6", "wkey", "wuuid", time.Now(),
test.WithPlatform("windows"), test.WithTeamID(teamNotDarwin.ID))
nanoEnrollAndSetHostMDMData(t, ds, hostWindows, false)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostWindows.UUID), "Windows host should NOT be eligible")
// Create host with pending status (already has SetRecoveryLock in progress)
// Note: SetHostsRecoveryLockPasswords now sets status to 'pending' atomically
teamPending := createTeamWithRecoveryLock("team-pending", true)
hostPending := test.NewHost(t, ds, "pending-host2", "1.2.5.7", "pkey2", "puuid2", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamPending.ID))
setHostCPUType(hostPending.ID, "arm64e")
nanoEnrollAndSetHostMDMData(t, ds, hostPending, false)
pendingPW := apple_mdm.GenerateRecoveryLockPassword()
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: hostPending.UUID, Password: pendingPW}})
require.NoError(t, err)
// Status is already 'pending' from SetHostsRecoveryLockPasswords - no need to call SetRecoveryLockPending
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostPending.UUID), "pending host should NOT be eligible")
// Create host with verified status (already has recovery lock set)
teamVerified := createTeamWithRecoveryLock("team-verified", true)
hostVerified := test.NewHost(t, ds, "verified-host2", "1.2.5.8", "vkey2", "vuuid2", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamVerified.ID))
setHostCPUType(hostVerified.ID, "arm64e")
nanoEnrollAndSetHostMDMData(t, ds, hostVerified, false)
verifiedPW := apple_mdm.GenerateRecoveryLockPassword()
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: hostVerified.UUID, Password: verifiedPW}})
require.NoError(t, err)
err = ds.SetRecoveryLockVerified(ctx, hostVerified.UUID)
require.NoError(t, err)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostVerified.UUID), "verified host should NOT be eligible")
// Test no-team host with app config recovery lock enabled
setAppConfigRecoveryLock(true)
hostNoTeam := test.NewHost(t, ds, "no-team-host", "1.2.5.9", "ntkey", "ntuuid", time.Now(),
test.WithPlatform("darwin"))
setHostCPUType(hostNoTeam.ID, "arm64e")
nanoEnrollAndSetHostMDMData(t, ds, hostNoTeam, false)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.True(t, slices.Contains(hosts, hostNoTeam.UUID), "no-team host should be eligible when app config enabled")
// Clean up - disable app config recovery lock
setAppConfigRecoveryLock(false)
// Now the no-team host should not be eligible
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostNoTeam.UUID), "no-team host should NOT be eligible when app config disabled")
// Create host with nano enrollment but MDM turned off (host_mdm.enrolled = 0)
// This tests that hosts are properly excluded after MDMTurnOff is called
teamUnenrolled := createTeamWithRecoveryLock("team-unenrolled", true)
hostUnenrolled := test.NewHost(t, ds, "unenrolled-host", "1.2.5.10", "uekey", "ueuuid", time.Now(),
test.WithPlatform("darwin"), test.WithTeamID(teamUnenrolled.ID))
setHostCPUType(hostUnenrolled.ID, "arm64e")
nanoEnroll(t, ds, hostUnenrolled, false)
// Set host_mdm with enrolled = false (simulates MDM turn off)
err = ds.SetOrUpdateMDMData(ctx, hostUnenrolled.ID, false, false, "", false, fleet.WellKnownMDMFleet, "", false)
require.NoError(t, err)
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
require.NoError(t, err)
assert.False(t, slices.Contains(hosts, hostUnenrolled.UUID), "host with MDM turned off should NOT be eligible")
}
@@ -0,0 +1,38 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260311160000, Down_20260311160000)
}
func Up_20260311160000(tx *sql.Tx) error {
if _, err := tx.Exec(`
CREATE TABLE host_recovery_key_passwords (
host_uuid varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
encrypted_password BLOB NOT NULL,
status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
operation_type VARCHAR(20) COLLATE utf8mb4_unicode_ci NOT NULL,
error_message TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL,
deleted TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (host_uuid),
KEY status (status),
KEY operation_type (operation_type),
KEY deleted (deleted),
CONSTRAINT host_recovery_key_passwords_ibfk_1 FOREIGN KEY (status) REFERENCES mdm_delivery_status (status) ON UPDATE CASCADE,
CONSTRAINT host_recovery_key_passwords_ibfk_2 FOREIGN KEY (operation_type) REFERENCES mdm_operation_types (operation_type) ON UPDATE CASCADE
)
`); err != nil {
return fmt.Errorf("creating host_recovery_key_passwords table: %w", err)
}
return nil
}
func Down_20260311160000(tx *sql.Tx) error {
return nil
}
@@ -292,6 +292,15 @@ func (s *NanoMDMStorage) ExpandEmbeddedSecrets(ctx context.Context, document str
return s.ds.ExpandEmbeddedSecrets(ctx, document)
}
// ExpandHostSecrets expands host-scoped secrets in the document using the enrollment ID.
func (s *NanoMDMStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
return s.ds.ExpandHostSecrets(ctx, document, enrollmentID)
}
func (s *NanoMDMStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
return s.ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg)
}
// ClearQueue in NanoMDMStorage overrides the implementation in
// nanomdm_mysql.MySQLStorage. It does call
// nanomdm_mysql.MySQLStorage.ClearQueue, but expands on its behavior.
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/xml"
"errors"
"fmt"
"strings"
"time"
@@ -436,3 +437,78 @@ func (ds *Datastore) ValidateEmbeddedSecrets(ctx context.Context, documents []st
return nil
}
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
// The enrollmentID (typically UDID/host UUID) is used to look up host-specific secrets.
func (ds *Datastore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
// Check for host secret placeholders
hostSecrets := fleet.ContainsPrefixVars(document, fleet.HostSecretPrefix)
if len(hostSecrets) == 0 {
return document, nil
}
// Build a map of secret type -> value
// enrollmentID is the host UUID, which is the primary key in host_recovery_key_passwords
secretValues := make(map[string]string)
for _, secretType := range hostSecrets {
switch secretType {
case fleet.HostSecretRecoveryLockPassword:
password, err := ds.getHostRecoveryLockPasswordDecrypted(ctx, enrollmentID)
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "getting recovery lock password for host %s", enrollmentID)
}
secretValues[secretType] = password
default:
return "", ctxerr.Errorf(ctx, "unknown host secret type: %s", secretType)
}
}
// Check if document is XML (same logic as expandEmbeddedSecrets)
documentIsXML := strings.HasPrefix(strings.TrimSpace(document), "<")
// Expand the placeholders
expanded := fleet.MaybeExpand(document, func(s string, startPos, endPos int) (string, bool) {
if !strings.HasPrefix(s, fleet.HostSecretPrefix) {
return "", false
}
secretType := strings.TrimPrefix(s, fleet.HostSecretPrefix)
val, ok := secretValues[secretType]
if !ok {
return "", false
}
if documentIsXML {
// Escape XML special characters to prevent malformed output
var b strings.Builder
if err := xml.EscapeText(&b, []byte(val)); err != nil {
return "", false
}
val = b.String()
}
return val, ok
})
return expanded, nil
}
// getHostRecoveryLockPasswordDecrypted retrieves and decrypts the recovery lock password for a host.
func (ds *Datastore) getHostRecoveryLockPasswordDecrypted(ctx context.Context, hostUUID string) (string, error) {
var encryptedPassword []byte
err := sqlx.GetContext(ctx, ds.reader(ctx), &encryptedPassword,
`SELECT encrypted_password FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`, hostUUID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
}
return "", ctxerr.Wrap(ctx, err, "getting encrypted recovery lock password")
}
password, err := decrypt(encryptedPassword, ds.serverPrivateKey)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "decrypting recovery lock password")
}
return string(password), nil
}
@@ -4,8 +4,10 @@ import (
"encoding/json"
"sort"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -20,6 +22,7 @@ func TestSecretVariables(t *testing.T) {
{"UpsertSecretVariables", testUpsertSecretVariables},
{"ValidateEmbeddedSecrets", testValidateEmbeddedSecrets},
{"ExpandEmbeddedSecrets", testExpandEmbeddedSecrets},
{"ExpandHostSecrets", testExpandHostSecrets},
{"CreateSecretVariable", testCreateSecretVariable},
{"ListSecretVariables", testListSecretVariables},
{"DeleteSecretVariable", testDeleteSecretVariable},
@@ -206,6 +209,121 @@ Hello doc${FLEET_SECRET_INVALID}. $FLEET_SECRET_ALSO_INVALID
require.Equal(t, expectedXMLExpansion, expanded)
}
func testExpandHostSecrets(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create a host
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host-secrets-test"),
NodeKey: ptr.String("host-secrets-test-key"),
UUID: "host-secrets-test-uuid",
Hostname: "host-secrets-test-hostname",
})
require.NoError(t, err)
// Set a recovery lock password for this host
password := "TEST-PASS-1234"
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
{HostUUID: host.UUID, Password: password},
})
require.NoError(t, err)
t.Run("no host secrets in document", func(t *testing.T) {
doc := "This document has no host secrets. $FLEET_SECRET_SOMETHING ${OTHER_VAR}"
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
require.NoError(t, err)
assert.Equal(t, doc, expanded) // unchanged
})
t.Run("expand recovery lock password", func(t *testing.T) {
doc := `<dict><key>NewPassword</key><string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string></dict>`
expected := `<dict><key>NewPassword</key><string>TEST-PASS-1234</string></dict>`
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
require.NoError(t, err)
assert.Equal(t, expected, expanded)
})
t.Run("expand with braces syntax", func(t *testing.T) {
doc := `Password: ${FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD}`
expected := `Password: TEST-PASS-1234`
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
require.NoError(t, err)
assert.Equal(t, expected, expanded)
})
t.Run("unknown host secret type", func(t *testing.T) {
doc := `<key>Value</key><string>$FLEET_HOST_SECRET_UNKNOWN_TYPE</string>`
_, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown host secret type")
})
t.Run("non-existent host", func(t *testing.T) {
doc := `<string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string>`
_, err := ds.ExpandHostSecrets(ctx, doc, "non-existent-uuid")
require.Error(t, err)
})
t.Run("host without recovery lock password", func(t *testing.T) {
// Create another host without a recovery lock password
host2, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host-no-password"),
NodeKey: ptr.String("host-no-password-key"),
UUID: "host-no-password-uuid",
Hostname: "host-no-password-hostname",
})
require.NoError(t, err)
doc := `<string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string>`
_, err = ds.ExpandHostSecrets(ctx, doc, host2.UUID)
require.Error(t, err)
assert.Contains(t, err.Error(), "getting recovery lock password")
})
t.Run("expand recovery lock password with XML special characters", func(t *testing.T) {
// Create a host with a password containing XML special characters
hostXML, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host-xml-escape-test"),
NodeKey: ptr.String("host-xml-escape-test-key"),
UUID: "host-xml-escape-test-uuid",
Hostname: "host-xml-escape-test-hostname",
})
require.NoError(t, err)
// Set a password with XML special characters: & < > " '
passwordWithSpecialChars := `Pass&word<with>special"chars'`
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
{HostUUID: hostXML.UUID, Password: passwordWithSpecialChars},
})
require.NoError(t, err)
// When expanded in an XML document, special characters should be escaped
doc := `<dict><key>NewPassword</key><string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string></dict>`
expected := `<dict><key>NewPassword</key><string>Pass&amp;word&lt;with&gt;special&#34;chars&#39;</string></dict>`
expanded, err := ds.ExpandHostSecrets(ctx, doc, hostXML.UUID)
require.NoError(t, err)
assert.Equal(t, expected, expanded)
// Non-XML documents should not escape the characters
docNonXML := `Password: $FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD`
expandedNonXML, err := ds.ExpandHostSecrets(ctx, docNonXML, hostXML.UUID)
require.NoError(t, err)
assert.Equal(t, `Password: Pass&word<with>special"chars'`, expandedNonXML)
})
}
func testCreateSecretVariable(t *testing.T, ds *Datastore) {
t.Run("successful creation", func(t *testing.T) {
ctx := t.Context()
+14
View File
@@ -27,6 +27,7 @@ type MDMAppleCommandIssuer interface {
EraseDevice(ctx context.Context, host *Host, uuid string) error
InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error
DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
}
// MDMAppleEnrollmentType is the type for Apple MDM enrollments.
@@ -1124,6 +1125,7 @@ const (
DeviceLocationCmdName = "DeviceLocation"
EnableLostModeCmdName = "EnableLostMode"
DisableLostModeCmdName = "DisableLostMode"
SetRecoveryLockCmdName = "SetRecoveryLock"
)
type HostLocationData struct {
@@ -1131,3 +1133,15 @@ type HostLocationData struct {
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
}
// HostRecoveryLockPassword represents a recovery lock password for a host.
type HostRecoveryLockPassword struct {
Password string
UpdatedAt time.Time
}
// HostRecoveryLockPasswordPayload contains the data needed to store a recovery lock password.
type HostRecoveryLockPasswordPayload struct {
HostUUID string
Password string
}
+3
View File
@@ -49,6 +49,9 @@ const (
// CronQueryResultsCleanup deletes excess query result rows that exceed the maximum allowed per query.
// Runs every 1 minute.
CronQueryResultsCleanup CronScheduleName = "query_results_cleanup"
// CronSendRecoveryLockCommands sends SetRecoveryLock MDM commands to macOS devices.
// Runs every 5 minutes.
CronSendRecoveryLockCommands CronScheduleName = "send_recovery_lock_commands"
)
type CronSchedulesService interface {
+32
View File
@@ -1493,6 +1493,33 @@ type Datastore interface {
// to any team).
GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*MDMAppleFileVaultSummary, error)
///////////////////////////////////////////////////////////////////////////////
// Apple MDM Recovery Lock Password
// SetHostsRecoveryLockPasswords encrypts and stores recovery lock passwords for the given hosts.
SetHostsRecoveryLockPasswords(ctx context.Context, passwords []HostRecoveryLockPasswordPayload) error
// GetHostRecoveryLockPassword retrieves and decrypts the recovery lock password
// for the given host UUID.
GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*HostRecoveryLockPassword, error)
// GetHostsForRecoveryLockAction returns host UUIDs that need recovery lock password action:
// - Teams with enable_recovery_lock_password = true
// - macOS Apple Silicon hosts that are MDM enrolled
// - No password saved or status is NULL (ready for command)
GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error)
// SetRecoveryLockVerified marks the recovery lock as verified.
SetRecoveryLockVerified(ctx context.Context, hostUUID string) error
// SetRecoveryLockFailed marks the recovery lock as failed with the given error message.
SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error
// ClearRecoveryLockPendingStatus resets the recovery lock status to NULL for hosts
// that failed to have their SetRecoveryLock commands enqueued. This allows them to
// be picked up again on the next cron run.
ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error
// InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the
// database (or S3 if configured).
InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error
@@ -2408,6 +2435,11 @@ type Datastore interface {
// returns the latest updated_at time of the secrets used in the expansion.
ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, document string) (string, *time.Time, error)
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
// The enrollmentID (typically UDID) is used to look up host-specific secrets
// like recovery lock passwords.
ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error)
// /////////////////////////////////////////////////////////////////////////////
// Android
+15
View File
@@ -7,6 +7,21 @@ import (
const ServerSecretPrefix = "FLEET_SECRET_"
// HostSecretPrefix is used for host-scoped secrets that are looked up by
// enrollment ID rather than by name. These are expanded at command delivery time.
//
// NOTE: This prefix is for Fleet-internal use only (e.g., injecting per-host
// recovery lock passwords into MDM commands). It is not user-configurable and
// should not be documented as a user-facing feature.
const HostSecretPrefix = "FLEET_HOST_SECRET_" //nolint:gosec // G101: this is a prefix constant, not a credential
// Host secret types
const (
// HostSecretRecoveryLockPassword is the host secret type for macOS recovery lock passwords.
// The password is stored encrypted in host_recovery_key_passwords and injected at delivery time.
HostSecretRecoveryLockPassword = "RECOVERY_LOCK_PASSWORD"
)
type MissingSecretsError struct {
MissingSecrets []string
}
+137
View File
@@ -3,6 +3,7 @@ package apple_mdm
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -1604,3 +1605,139 @@ func IOSiPadOSRevive(ctx context.Context, ds fleet.Datastore, commander *MDMAppl
}
return nil
}
// RecoveryLockCommander defines the interface for sending recovery lock commands.
// This interface is implemented by MDMAppleCommander and allows for testing.
type RecoveryLockCommander interface {
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
}
// SendRecoveryLockCommands is the cron job function that sends SetRecoveryLock MDM commands
// to hosts that need a recovery lock password.
//
// Note: SetRecoveryLock command results are handled in the MDM results handler
// (server/service/apple_mdm.go), which sends VerifyRecoveryLock immediately upon acknowledgment.
func SendRecoveryLockCommands(
ctx context.Context,
ds fleet.Datastore,
commander *MDMAppleCommander,
logger *slog.Logger,
) error {
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger)
}
func sendRecoveryLockCommandsWithCommander(
ctx context.Context,
ds fleet.Datastore,
commander RecoveryLockCommander,
logger *slog.Logger,
) error {
hosts, err := ds.GetHostsForRecoveryLockAction(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get hosts for recovery lock action")
}
if len(hosts) == 0 {
logger.DebugContext(ctx, "no hosts need SetRecoveryLock")
return nil
}
logger.InfoContext(ctx, "sending SetRecoveryLock commands", "count", len(hosts))
// Generate passwords for all hosts upfront.
// Passwords must be stored BEFORE enqueuing commands because they are injected
// at delivery time by ExpandHostSecrets (which looks up by host UUID).
passwords := make([]fleet.HostRecoveryLockPasswordPayload, 0, len(hosts))
for _, hostUUID := range hosts {
passwords = append(passwords, fleet.HostRecoveryLockPasswordPayload{
HostUUID: hostUUID,
Password: GenerateRecoveryLockPassword(),
})
}
// Store passwords with status='pending' atomically. This prevents the host from
// being picked up again by the next cron run while we're enqueuing the command.
// If enqueue fails, we reset the status to NULL so the host can be retried.
if err := ds.SetHostsRecoveryLockPasswords(ctx, passwords); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set recovery lock passwords")
}
// Collect host UUIDs for enqueue.
// The password is not in the command - a placeholder is used that will be
// expanded at delivery time by ExpandHostSecrets.
hostUUIDs := make([]string, 0, len(passwords))
for _, p := range passwords {
hostUUIDs = append(hostUUIDs, p.HostUUID)
}
// Enqueue a single command for all hosts. Each host gets their own queue entry
// pointing to the same command, and ExpandHostSecrets injects the per-host
// password at delivery time.
cmdUUID := uuid.NewString()
if err := commander.SetRecoveryLock(ctx, hostUUIDs, cmdUUID); err != nil {
// Check if this is an APNs delivery error (command was persisted but push failed).
// In this case, the command is already queued and will be delivered when the device
// checks in, so we should NOT clear the pending status (which would cause duplicates).
var apnsErr *APNSDeliveryError
if errors.As(err, &apnsErr) {
// Command was persisted but push notification failed - log warning but don't fail.
// The command will be delivered when the device next checks in.
logger.WarnContext(ctx, "SetRecoveryLock commands enqueued but APNs push failed",
"host_count", len(hostUUIDs),
"command_uuid", cmdUUID,
"error", err,
)
// Don't clear pending status - command is queued and will be processed
return nil
}
// Persistence failed - reset status to NULL so hosts will be picked up again on next cron run.
// The password is already stored, but a new one will be generated on retry (overwrites old).
logger.ErrorContext(ctx, "failed to enqueue SetRecoveryLock commands",
"host_count", len(hostUUIDs),
"error", err,
)
if clearErr := ds.ClearRecoveryLockPendingStatus(ctx, hostUUIDs); clearErr != nil {
logger.ErrorContext(ctx, "failed to clear recovery lock pending status after enqueue failure",
"host_count", len(hostUUIDs),
"error", clearErr,
)
}
return ctxerr.Wrap(ctx, err, "enqueue SetRecoveryLock commands")
}
logger.InfoContext(ctx, "sent SetRecoveryLock commands",
"host_count", len(hostUUIDs),
"command_uuid", cmdUUID,
)
return nil
}
// RecoveryLockPasswordCharset excludes confusing characters (0/O, 1/I/l)
const RecoveryLockPasswordCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
// GenerateRecoveryLockPassword generates a password in format: 5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT
// (6 groups of 4 alphanumeric characters separated by dashes)
func GenerateRecoveryLockPassword() string {
const (
groupCount = 6
groupLen = 4
)
groups := make([]string, groupCount)
charsetLen := len(RecoveryLockPasswordCharset)
for i := range groupCount {
randBytes := make([]byte, groupLen)
_, _ = rand.Read(randBytes) // rand.Read never returns an error; it panics on failure
group := make([]byte, groupLen)
for j := range groupLen {
group[j] = RecoveryLockPasswordCharset[int(randBytes[j])%charsetLen]
}
groups[i] = string(group)
}
return strings.Join(groups, "-")
}
+193
View File
@@ -3,10 +3,12 @@ package apple_mdm
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"time"
@@ -273,3 +275,194 @@ type notFoundError struct{}
func (e notFoundError) IsNotFound() bool { return true }
func (e notFoundError) Error() string { return "not found" }
func TestGenerateRecoveryLockPassword(t *testing.T) {
// Pattern: 6 groups of 4 characters from the allowed charset, separated by dashes
pattern := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}(-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}){5}$`)
t.Run("format", func(t *testing.T) {
password := GenerateRecoveryLockPassword()
assert.True(t, pattern.MatchString(password), "password %q does not match expected format", password)
assert.Len(t, password, 29) // 24 chars + 5 dashes
})
t.Run("excludes confusing characters", func(t *testing.T) {
// Generate multiple passwords and check none contain confusing chars
confusingChars := regexp.MustCompile(`[01OIl]`)
for range 100 {
password := GenerateRecoveryLockPassword()
assert.False(t, confusingChars.MatchString(password), "password %q contains confusing characters", password)
}
})
t.Run("uniqueness", func(t *testing.T) {
// Generate multiple passwords and verify they're unique
seen := make(map[string]bool)
for range 100 {
password := GenerateRecoveryLockPassword()
assert.False(t, seen[password], "duplicate password generated: %s", password)
seen[password] = true
}
})
}
// TestSendRecoveryLockCommands tests the cron job that sends SetRecoveryLock commands
// to hosts that need recovery lock passwords.
//
// Note: SetRecoveryLock command results are handled synchronously in the MDM results handler
// (server/service/apple_mdm.go), which is tested separately in apple_mdm_cmd_results_test.go.
func TestSendRecoveryLockCommands(t *testing.T) {
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
t.Run("no hosts needing recovery lock does not send commands", func(t *testing.T) {
ds := new(mock.Store)
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
var commandSent bool
mockCommander := &mockRecoveryLockCommander{
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
commandSent = true
return nil
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
require.NoError(t, err)
assert.False(t, commandSent, "SetRecoveryLock should not be called when no hosts need it")
})
t.Run("host needing recovery lock gets SetRecoveryLock and password stored with pending status", func(t *testing.T) {
ds := new(mock.Store)
hostUUID := "host-uuid-1"
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
return []string{hostUUID}, nil
}
// Track call order to verify correct sequencing
var callOrder []string
var storedPasswords []fleet.HostRecoveryLockPasswordPayload
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
storedPasswords = passwords
return nil
}
var sentCmdUUID string
mockCommander := &mockRecoveryLockCommander{
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
callOrder = append(callOrder, "SetRecoveryLock")
assert.Equal(t, []string{hostUUID}, hostUUIDs)
sentCmdUUID = cmdUUID
return nil
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
require.NoError(t, err)
// Verify call order: password must be stored BEFORE command is sent
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock"}, callOrder,
"SetHostsRecoveryLockPasswords must be called before SetRecoveryLock")
// Password should be stored with pending status atomically
require.Len(t, storedPasswords, 1, "password should be stored for host")
assert.Equal(t, hostUUID, storedPasswords[0].HostUUID)
assert.NotEmpty(t, storedPasswords[0].Password)
assert.NotEmpty(t, sentCmdUUID, "command UUID should have been sent")
})
t.Run("SetRecoveryLock failure clears pending status to allow retry", func(t *testing.T) {
ds := new(mock.Store)
hostUUID := "host-uuid-1"
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
return []string{hostUUID}, nil
}
// Track call order to verify correct sequencing
var callOrder []string
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
return nil
}
var clearedHostUUIDs []string
ds.ClearRecoveryLockPendingStatusFunc = func(ctx context.Context, hostUUIDs []string) error {
callOrder = append(callOrder, "ClearRecoveryLockPendingStatus")
clearedHostUUIDs = hostUUIDs
return nil
}
mockCommander := &mockRecoveryLockCommander{
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
callOrder = append(callOrder, "SetRecoveryLock")
return errors.New("APNs push failed")
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
require.Error(t, err)
assert.Contains(t, err.Error(), "APNs push failed")
// Verify call order: password stored -> command attempt -> clear pending on failure
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock", "ClearRecoveryLockPendingStatus"}, callOrder,
"Operations must occur in order: store password, attempt command, clear pending on failure")
// Status should be cleared to allow retry on next cron run
assert.Equal(t, []string{hostUUID}, clearedHostUUIDs, "pending status should be cleared on enqueue failure")
})
t.Run("APNs delivery failure does not clear pending status", func(t *testing.T) {
ds := new(mock.Store)
hostUUID := "host-uuid-1"
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
return []string{hostUUID}, nil
}
// Track call order to verify ClearRecoveryLockPendingStatus is NOT called
var callOrder []string
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
return nil
}
ds.ClearRecoveryLockPendingStatusFunc = func(ctx context.Context, hostUUIDs []string) error {
callOrder = append(callOrder, "ClearRecoveryLockPendingStatus")
return nil
}
mockCommander := &mockRecoveryLockCommander{
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
callOrder = append(callOrder, "SetRecoveryLock")
// Return APNs delivery error - command was persisted but push failed
return &APNSDeliveryError{errorsByUUID: map[string]error{hostUUID: errors.New("push failed")}}
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
// Should NOT return error - command was persisted, just push failed
require.NoError(t, err)
// Verify ClearRecoveryLockPendingStatus was NOT called (status should stay pending)
// Command is queued and will be delivered when device checks in
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock"}, callOrder,
"ClearRecoveryLockPendingStatus should NOT be called when APNs push fails (command is already queued)")
})
}
// mockRecoveryLockCommander implements RecoveryLockCommander for testing.
type mockRecoveryLockCommander struct {
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
}
func (m *mockRecoveryLockCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
if m.setRecoveryLockFn != nil {
return m.setRecoveryLockFn(ctx, hostUUIDs, cmdUUID)
}
return nil
}
+27
View File
@@ -562,6 +562,33 @@ func (svc *MDMAppleCommander) BulkDeleteHostUserCommandsWithoutResults(ctx conte
return svc.storage.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs)
}
// SetRecoveryLock sends the SetRecoveryLock MDM command to set the recovery lock password.
// The password is not included in the command - instead, a placeholder is used that will be
// expanded at delivery time by looking up the password from host_recovery_key_passwords.
// The password must be stored (via SetHostsRecoveryLockPasswords) BEFORE calling this method.
// See https://developer.apple.com/documentation/devicemanagement/set_recovery_lock
func (svc *MDMAppleCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
// Use the host secret placeholder - the actual password will be injected at delivery time
// by ExpandHostSecrets, which looks up the password from host_recovery_key_passwords.
cmdPayload := commandPayload{
CommandUUID: cmdUUID,
Command: map[string]any{
"RequestType": "SetRecoveryLock",
"NewPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPassword,
},
}
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
if err != nil {
return ctxerr.Wrap(ctx, err, "marshalling SetRecoveryLock payload")
}
if err := svc.EnqueueCommand(ctx, hostUUIDs, string(rawBytes)); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing SetRecoveryLock command")
}
return nil
}
// APNSDeliveryError records an error and the associated host UUIDs in which it
// occurred.
type APNSDeliveryError struct {
+54
View File
@@ -569,3 +569,57 @@ UUID: uuid3, Error: timeout error`,
})
}
}
func TestMDMAppleCommanderSetRecoveryLock(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, "SetRecoveryLock", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), cmdUUID)
require.Contains(t, string(cmd.Raw), "SetRecoveryLock")
// Should contain the placeholder, not the actual password
require.Contains(t, string(cmd.Raw), "$"+fleet.HostSecretPrefix+fleet.HostSecretRecoveryLockPassword)
require.Contains(t, string(cmd.Raw), "<key>NewPassword</key>")
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.SetRecoveryLock(ctx, hostUUIDs, cmdUUID)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
}
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
@@ -284,6 +285,38 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
} else {
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 {
hostUUID := results.UDID
hostExpanded, err := s.store.ExpandHostSecrets(r.Context, string(cmd.Raw), hostUUID)
if err != nil {
errorMsg := fmt.Sprintf("failed to expand host secrets: %v", err)
logger.Info("level", "error", "msg", "expanding host secrets", "err", err)
// Mark the command as failed so it won't be retried forever
failedResult := &mdm.CommandResults{
Enrollment: results.Enrollment,
CommandUUID: cmd.CommandUUID,
Status: "Error",
ErrorChain: []mdm.ErrorChain{{
ErrorCode: -1,
ErrorDomain: "Fleet",
LocalizedDescription: errorMsg,
}},
}
if storeErr := s.store.StoreCommandReport(r, failedResult); storeErr != nil {
logger.Info("level", "error", "msg", "storing failed command result", "err", storeErr)
}
// 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)
}
return nil, nil
}
cmd.Raw = []byte(hostExpanded)
}
switch cmd.Subtype {
case mdm.CommandSubtypeProfileWithSecrets:
// Secrets were expanded above. Now we need to base64 encode and sign the configuration profile before returning it to the caller.
@@ -105,6 +105,16 @@ func (ms *MultiAllStorage) ExpandEmbeddedSecrets(ctx context.Context, document s
return doc.(string), err
}
func (ms *MultiAllStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
// NOT IMPLEMENTED
return document, nil
}
func (ms *MultiAllStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
// NOT IMPLEMENTED
return nil
}
func (ms *MultiAllStorage) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error {
_, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return nil, s.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs)
+10
View File
@@ -246,6 +246,16 @@ func (s *FileStorage) ExpandEmbeddedSecrets(_ context.Context, document string)
return document, nil
}
func (s *FileStorage) ExpandHostSecrets(_ context.Context, document string, _ string) (string, error) {
// NOT IMPLEMENTED
return document, nil
}
func (s *FileStorage) SetRecoveryLockFailed(_ context.Context, _ string, _ string) error {
// NOT IMPLEMENTED
return nil
}
func (s *FileStorage) BulkDeleteHostUserCommandsWithoutResults(_ context.Context, _ map[string][]string) error {
// NOT IMPLEMENTED
return nil
+10
View File
@@ -348,3 +348,13 @@ func (s *MySQLStorage) ExpandEmbeddedSecrets(ctx context.Context, document strin
s.logger.ErrorContext(ctx, "MySQLStorage.ExpandEmbeddedSecrets not implemented")
return document, nil
}
func (s *MySQLStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
s.logger.ErrorContext(ctx, "MySQLStorage.ExpandHostSecrets not implemented")
return document, nil
}
func (s *MySQLStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
s.logger.ErrorContext(ctx, "MySQLStorage.SetRecoveryLockFailed not implemented")
return nil
}
+8
View File
@@ -40,7 +40,15 @@ type BootstrapTokenStore interface {
}
type SecretStore interface {
// ExpandEmbeddedSecrets expands named secrets ($FLEET_SECRET_*) in the document.
ExpandEmbeddedSecrets(ctx context.Context, document string) (string, error)
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
// The enrollmentID (host UUID) is used to look up host-specific secrets like recovery lock passwords.
ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error)
// SetRecoveryLockFailed marks a host's recovery lock as failed.
// The hostUUID is the same as the enrollment ID (UDID).
// Used when secret expansion fails and we need to update the host status.
SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error
}
// ServiceStore stores & retrieves both command and check-in data.
+84
View File
@@ -1023,6 +1023,18 @@ type GetMDMIdPAccountsByHostUUIDsFunc func(ctx context.Context, hostUUIDs []stri
type GetMDMAppleFileVaultSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error)
type SetHostsRecoveryLockPasswordsFunc func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error
type GetHostRecoveryLockPasswordFunc func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error)
type GetHostsForRecoveryLockActionFunc func(ctx context.Context) ([]string, error)
type SetRecoveryLockVerifiedFunc func(ctx context.Context, hostUUID string) error
type SetRecoveryLockFailedFunc func(ctx context.Context, hostUUID string, errorMsg string) error
type ClearRecoveryLockPendingStatusFunc func(ctx context.Context, hostUUIDs []string) error
type InsertMDMAppleBootstrapPackageFunc func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error
type CopyDefaultMDMAppleBootstrapPackageFunc func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error
@@ -1537,6 +1549,8 @@ type ExpandEmbeddedSecretsFunc func(ctx context.Context, document string) (strin
type ExpandEmbeddedSecretsAndUpdatedAtFunc func(ctx context.Context, document string) (string, *time.Time, error)
type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error)
type CreateEnterpriseFunc func(ctx context.Context, userID uint) (uint, error)
type GetEnterpriseByIDFunc func(ctx context.Context, id uint) (*android.EnterpriseDetails, error)
@@ -3290,6 +3304,24 @@ type DataStore struct {
GetMDMAppleFileVaultSummaryFunc GetMDMAppleFileVaultSummaryFunc
GetMDMAppleFileVaultSummaryFuncInvoked bool
SetHostsRecoveryLockPasswordsFunc SetHostsRecoveryLockPasswordsFunc
SetHostsRecoveryLockPasswordsFuncInvoked bool
GetHostRecoveryLockPasswordFunc GetHostRecoveryLockPasswordFunc
GetHostRecoveryLockPasswordFuncInvoked bool
GetHostsForRecoveryLockActionFunc GetHostsForRecoveryLockActionFunc
GetHostsForRecoveryLockActionFuncInvoked bool
SetRecoveryLockVerifiedFunc SetRecoveryLockVerifiedFunc
SetRecoveryLockVerifiedFuncInvoked bool
SetRecoveryLockFailedFunc SetRecoveryLockFailedFunc
SetRecoveryLockFailedFuncInvoked bool
ClearRecoveryLockPendingStatusFunc ClearRecoveryLockPendingStatusFunc
ClearRecoveryLockPendingStatusFuncInvoked bool
InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFunc
InsertMDMAppleBootstrapPackageFuncInvoked bool
@@ -4061,6 +4093,9 @@ type DataStore struct {
ExpandEmbeddedSecretsAndUpdatedAtFunc ExpandEmbeddedSecretsAndUpdatedAtFunc
ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked bool
ExpandHostSecretsFunc ExpandHostSecretsFunc
ExpandHostSecretsFuncInvoked bool
CreateEnterpriseFunc CreateEnterpriseFunc
CreateEnterpriseFuncInvoked bool
@@ -7942,6 +7977,48 @@ func (s *DataStore) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uin
return s.GetMDMAppleFileVaultSummaryFunc(ctx, teamID)
}
func (s *DataStore) SetHostsRecoveryLockPasswords(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
s.mu.Lock()
s.SetHostsRecoveryLockPasswordsFuncInvoked = true
s.mu.Unlock()
return s.SetHostsRecoveryLockPasswordsFunc(ctx, passwords)
}
func (s *DataStore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
s.mu.Lock()
s.GetHostRecoveryLockPasswordFuncInvoked = true
s.mu.Unlock()
return s.GetHostRecoveryLockPasswordFunc(ctx, hostUUID)
}
func (s *DataStore) GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error) {
s.mu.Lock()
s.GetHostsForRecoveryLockActionFuncInvoked = true
s.mu.Unlock()
return s.GetHostsForRecoveryLockActionFunc(ctx)
}
func (s *DataStore) SetRecoveryLockVerified(ctx context.Context, hostUUID string) error {
s.mu.Lock()
s.SetRecoveryLockVerifiedFuncInvoked = true
s.mu.Unlock()
return s.SetRecoveryLockVerifiedFunc(ctx, hostUUID)
}
func (s *DataStore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
s.mu.Lock()
s.SetRecoveryLockFailedFuncInvoked = true
s.mu.Unlock()
return s.SetRecoveryLockFailedFunc(ctx, hostUUID, errorMsg)
}
func (s *DataStore) ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error {
s.mu.Lock()
s.ClearRecoveryLockPendingStatusFuncInvoked = true
s.mu.Unlock()
return s.ClearRecoveryLockPendingStatusFunc(ctx, hostUUIDs)
}
func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error {
s.mu.Lock()
s.InsertMDMAppleBootstrapPackageFuncInvoked = true
@@ -9741,6 +9818,13 @@ func (s *DataStore) ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, docum
return s.ExpandEmbeddedSecretsAndUpdatedAtFunc(ctx, document)
}
func (s *DataStore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
s.mu.Lock()
s.ExpandHostSecretsFuncInvoked = true
s.mu.Unlock()
return s.ExpandHostSecretsFunc(ctx, document, enrollmentID)
}
func (s *DataStore) CreateEnterprise(ctx context.Context, userID uint) (uint, error) {
s.mu.Lock()
s.CreateEnterpriseFuncInvoked = true
+24
View File
@@ -37,6 +37,10 @@ type RetrieveBootstrapTokenFunc func(r *mdm.Request, msg *mdm.GetBootstrapToken)
type ExpandEmbeddedSecretsFunc func(ctx context.Context, document string) (string, error)
type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error)
type SetRecoveryLockFailedFunc func(ctx context.Context, hostUUID string, errorMsg string) error
type RetrievePushInfoFunc func(ctx context.Context, ids []string) (map[string]*mdm.Push, error)
type IsPushCertStaleFunc func(ctx context.Context, topic string, staleToken string) (bool, error)
@@ -107,6 +111,12 @@ type MDMAppleStore struct {
ExpandEmbeddedSecretsFunc ExpandEmbeddedSecretsFunc
ExpandEmbeddedSecretsFuncInvoked bool
ExpandHostSecretsFunc ExpandHostSecretsFunc
ExpandHostSecretsFuncInvoked bool
SetRecoveryLockFailedFunc SetRecoveryLockFailedFunc
SetRecoveryLockFailedFuncInvoked bool
RetrievePushInfoFunc RetrievePushInfoFunc
RetrievePushInfoFuncInvoked bool
@@ -241,6 +251,20 @@ func (fs *MDMAppleStore) ExpandEmbeddedSecrets(ctx context.Context, document str
return fs.ExpandEmbeddedSecretsFunc(ctx, document)
}
func (fs *MDMAppleStore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
fs.mu.Lock()
fs.ExpandHostSecretsFuncInvoked = true
fs.mu.Unlock()
return fs.ExpandHostSecretsFunc(ctx, document, enrollmentID)
}
func (fs *MDMAppleStore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
fs.mu.Lock()
fs.SetRecoveryLockFailedFuncInvoked = true
fs.mu.Unlock()
return fs.SetRecoveryLockFailedFunc(ctx, hostUUID, errorMsg)
}
func (fs *MDMAppleStore) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
fs.mu.Lock()
fs.RetrievePushInfoFuncInvoked = true
+75
View File
@@ -3891,6 +3891,12 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
return nil, ctxerr.Wrap(r.Context, err, "DeviceLocation: calling handlers")
}
}
case fleet.SetRecoveryLockCmdName:
res := NewRecoveryLockResult(cmdResult)
if err := svc.runCommandHandlers(r.Context, fleet.SetRecoveryLockCmdName, res); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "SetRecoveryLock: calling handlers")
}
}
return nil, nil
@@ -7313,3 +7319,72 @@ func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, dep
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Apple MDM Recovery Lock Password
// recoveryLockResult wraps mdm.CommandResults to implement fleet.MDMCommandResults
type recoveryLockResult struct {
cmdResult *mdm.CommandResults
}
func (r *recoveryLockResult) Raw() []byte { return r.cmdResult.Raw }
func (r *recoveryLockResult) UUID() string { return r.cmdResult.CommandUUID }
func (r *recoveryLockResult) HostUUID() string { return r.cmdResult.UDID } // SetRecoveryLock is device-only, UDID is always present
// NewRecoveryLockResult wraps an mdm.CommandResults to implement fleet.MDMCommandResults
func NewRecoveryLockResult(cmdResult *mdm.CommandResults) fleet.MDMCommandResults {
return &recoveryLockResult{cmdResult: cmdResult}
}
// NewSetRecoveryLockResultsHandler processes SetRecoveryLock command results.
// When a SetRecoveryLock command is acknowledged, it marks the recovery lock as verified.
// On error, it marks the recovery lock as failed.
func NewSetRecoveryLockResultsHandler(
ds fleet.Datastore,
logger *slog.Logger,
) fleet.MDMCommandResultsHandler {
return func(ctx context.Context, results fleet.MDMCommandResults) error {
// Get the underlying result to access status and error chain
rlResult, ok := results.(*recoveryLockResult)
if !ok {
return ctxerr.New(ctx, "SetRecoveryLock handler: unexpected results type")
}
hostUUID := results.HostUUID()
status := rlResult.cmdResult.Status
logger.DebugContext(ctx, "SetRecoveryLock command result received",
"host_uuid", hostUUID,
"command_uuid", results.UUID(),
"status", status,
)
switch status {
case fleet.MDMAppleStatusAcknowledged:
// ACK means the password was successfully applied - mark as verified
if err := ds.SetRecoveryLockVerified(ctx, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock verified")
}
logger.InfoContext(ctx, "SetRecoveryLock acknowledged, marked verified",
"host_uuid", hostUUID,
)
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
errorMsg := apple_mdm.FmtErrorChain(rlResult.cmdResult.ErrorChain)
if errorMsg == "" {
errorMsg = "SetRecoveryLock command failed"
}
if err := ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg); err != nil {
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock failed")
}
logger.WarnContext(ctx, "SetRecoveryLock command failed",
"host_uuid", hostUUID,
"error", errorMsg,
)
}
return nil
}
}
@@ -284,3 +284,90 @@ func TestInstalledApplicationListHandler(t *testing.T) {
assert.True(t, ds.NewJobFuncInvoked, "should queue a polling job when expected app not in list")
})
}
func TestSetRecoveryLockResultsHandler(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
hostUUID := "test-host-uuid"
cmdUUID := "set-recovery-lock-cmd-uuid"
t.Run("acknowledged sets verified", func(t *testing.T) {
ds := new(mock.DataStore)
var verifiedCalled bool
ds.SetRecoveryLockVerifiedFunc = func(_ context.Context, hUUID string) error {
verifiedCalled = true
assert.Equal(t, hostUUID, hUUID)
return nil
}
handler := NewSetRecoveryLockResultsHandler(ds, logger)
result := NewRecoveryLockResult(&mdm.CommandResults{
Enrollment: mdm.Enrollment{UDID: hostUUID},
CommandUUID: cmdUUID,
Status: fleet.MDMAppleStatusAcknowledged,
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
})
err := handler(ctx, result)
require.NoError(t, err)
// Verify status was set to verified
assert.True(t, verifiedCalled)
})
t.Run("error status sets failed", func(t *testing.T) {
ds := new(mock.DataStore)
var failedCalled bool
var capturedError string
ds.SetRecoveryLockFailedFunc = func(_ context.Context, hUUID string, errorMsg string) error {
failedCalled = true
assert.Equal(t, hostUUID, hUUID)
capturedError = errorMsg
return nil
}
handler := NewSetRecoveryLockResultsHandler(ds, logger)
result := NewRecoveryLockResult(&mdm.CommandResults{
Enrollment: mdm.Enrollment{UDID: hostUUID},
CommandUUID: cmdUUID,
Status: fleet.MDMAppleStatusError,
ErrorChain: []mdm.ErrorChain{{ErrorCode: 12345, ErrorDomain: "test", LocalizedDescription: "Test error"}},
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
})
err := handler(ctx, result)
require.NoError(t, err)
assert.True(t, failedCalled)
assert.Contains(t, capturedError, "Test error")
})
t.Run("command format error sets failed with default message", func(t *testing.T) {
ds := new(mock.DataStore)
var capturedError string
ds.SetRecoveryLockFailedFunc = func(_ context.Context, hUUID string, errorMsg string) error {
capturedError = errorMsg
return nil
}
handler := NewSetRecoveryLockResultsHandler(ds, logger)
result := NewRecoveryLockResult(&mdm.CommandResults{
Enrollment: mdm.Enrollment{UDID: hostUUID},
CommandUUID: cmdUUID,
Status: fleet.MDMAppleStatusCommandFormatError,
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
})
err := handler(ctx, result)
require.NoError(t, err)
assert.Equal(t, "SetRecoveryLock command failed", capturedError)
})
}
+1
View File
@@ -524,6 +524,7 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
checkInAndCommand := NewMDMAppleCheckinAndCommandService(ds, commander, vppInstaller, opts[0].License.IsPremium(), logger, redis_key_value.New(redisPool), svc.NewActivity)
checkInAndCommand.RegisterResultsHandler("InstalledApplicationList", NewInstalledApplicationListResultsHandler(ds, commander, logger, cfg.Server.VPPVerifyTimeout, cfg.Server.VPPVerifyRequestDelay, svc.NewActivity))
checkInAndCommand.RegisterResultsHandler(fleet.DeviceLocationCmdName, NewDeviceLocationResultsHandler(ds, commander, logger))
checkInAndCommand.RegisterResultsHandler(fleet.SetRecoveryLockCmdName, NewSetRecoveryLockResultsHandler(ds, logger))
err := RegisterAppleMDMProtocolServices(
rootMux,
cfg.MDM,