Backend: Auto rotate recovery lock passwords (#42084)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41670 

# 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.

- [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.

## Testing

- [X] Added/updated automated tests

- [ ] QA'd all new/changed functionality manually

## Database migrations

- [X] Checked schema for all modified table for columns that will
auto-update timestamps during migration.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automatic recovery lock password rotation for Mac devices—passwords
now rotate 1 hour after being viewed or accessed via the API, enhancing
security.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tim Lee 2026-03-26 12:12:41 -06:00 committed by GitHub
parent a74901ea5d
commit 1aef647195
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 777 additions and 31 deletions

View file

@ -0,0 +1 @@
- Added automatic rotation of Mac recovery lock passwords 1 hour after the password is viewed via the API.

View file

@ -2034,6 +2034,7 @@ func newRecoveryLockPasswordSchedule(
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger *slog.Logger,
newActivityFn fleet.NewActivityFunc,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronSendRecoveryLockCommands)
@ -2045,7 +2046,7 @@ func newRecoveryLockPasswordSchedule(
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 apple_mdm.SendRecoveryLockCommands(ctx, ds, commander, logger, newActivityFn)
}),
)

View file

@ -1356,7 +1356,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger)
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger, svc.NewActivity)
}); err != nil {
initFatal(err, "failed to register recovery lock password schedule")
}

View file

@ -7323,11 +7323,12 @@ func (ds *Datastore) SetHostsRecoveryLockPasswords(ctx context.Context, password
}
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`
const stmt = `SELECT encrypted_password, updated_at, auto_rotate_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"`
EncryptedPassword []byte `db:"encrypted_password"`
UpdatedAt time.Time `db:"updated_at"`
AutoRotateAt *time.Time `db:"auto_rotate_at"`
}
if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostUUID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@ -7343,8 +7344,9 @@ func (ds *Datastore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID s
}
return &fleet.HostRecoveryLockPassword{
Password: string(decrypted),
UpdatedAt: row.UpdatedAt,
Password: string(decrypted),
UpdatedAt: row.UpdatedAt,
AutoRotateAt: row.AutoRotateAt,
}, nil
}
@ -7680,10 +7682,10 @@ func (ds *Datastore) InitiateRecoveryLockRotation(ctx context.Context, hostUUID
}
if dest.HasPending {
return ctxerr.Errorf(ctx, "rotation already pending for host %s", hostUUID)
return ctxerr.Wrap(ctx, fleet.ErrRecoveryLockRotationPending, fmt.Sprintf("host %s", hostUUID))
}
return ctxerr.Errorf(ctx, "host %s not eligible for rotation (status=%v, operation_type=%v)", hostUUID, dest.Status.String, dest.OperationType.String)
return ctxerr.Wrap(ctx, fleet.ErrRecoveryLockNotEligible, fmt.Sprintf("host %s (status=%v, operation_type=%v)", hostUUID, dest.Status.String, dest.OperationType.String))
}
return nil
@ -7691,13 +7693,15 @@ func (ds *Datastore) InitiateRecoveryLockRotation(ctx context.Context, hostUUID
func (ds *Datastore) CompleteRecoveryLockRotation(ctx context.Context, hostUUID string) error {
// Move pending password to active and clear pending columns.
// Also clear auto_rotate_at since rotation is now complete.
stmt := fmt.Sprintf(`
UPDATE host_recovery_key_passwords
SET encrypted_password = pending_encrypted_password,
pending_encrypted_password = NULL,
pending_error_message = NULL,
status = '%s',
error_message = NULL
error_message = NULL,
auto_rotate_at = NULL
WHERE host_uuid = ?
AND deleted = 0
AND pending_encrypted_password IS NOT NULL
@ -7839,3 +7843,62 @@ func (ds *Datastore) HasPendingRecoveryLockRotation(ctx context.Context, hostUUI
return hasPending, nil
}
func (ds *Datastore) MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error) {
// Set auto_rotate_at to 1 hour from now when password is viewed.
// This always updates (even if pending rotation exists) so the API always returns a valid rotation time.
rotateAt := time.Now().Add(1 * time.Hour)
stmt := fmt.Sprintf(`
UPDATE host_recovery_key_passwords
SET auto_rotate_at = ?
WHERE host_uuid = ?
AND deleted = 0
AND operation_type = '%s'
`, fleet.MDMOperationTypeInstall)
result, err := ds.writer(ctx).ExecContext(ctx, stmt, rotateAt, hostUUID)
if err != nil {
return time.Time{}, ctxerr.Wrap(ctx, err, "mark recovery lock password viewed")
}
rows, _ := result.RowsAffected()
if rows == 0 {
return time.Time{}, ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
}
return rotateAt, nil
}
func (ds *Datastore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
// Return hosts where:
// - auto_rotate_at is in the past (due for rotation)
// - status is verified (password is confirmed working)
// - no pending rotation (pending_encrypted_password IS NULL)
// - operation_type is install (not in remove state)
// - not deleted
// Join with hosts table to get host ID and display name for activity logging.
stmt := fmt.Sprintf(`
SELECT
hrkp.host_uuid,
h.id AS host_id,
COALESCE(NULLIF(h.computer_name, ''), h.hostname) AS display_name
FROM host_recovery_key_passwords hrkp
JOIN hosts h ON h.uuid = hrkp.host_uuid
WHERE hrkp.auto_rotate_at IS NOT NULL
AND hrkp.auto_rotate_at <= NOW(6)
AND hrkp.status = '%s'
AND hrkp.pending_encrypted_password IS NULL
AND hrkp.operation_type = '%s'
AND hrkp.deleted = 0
LIMIT 100
`, fleet.MDMDeliveryVerified, fleet.MDMOperationTypeInstall)
var hosts []fleet.HostAutoRotationInfo
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, stmt); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get hosts for auto rotation")
}
return hosts, nil
}

View file

@ -123,6 +123,7 @@ func TestMDMApple(t *testing.T) {
{"GetHostRecoveryLockPasswordStatus", testGetHostRecoveryLockPasswordStatus},
{"ClaimHostsForRecoveryLockClear", testClaimHostsForRecoveryLockClear},
{"RecoveryLockRotation", testRecoveryLockRotation},
{"RecoveryLockAutoRotation", testRecoveryLockAutoRotation},
}
for _, c := range cases {
@ -11207,7 +11208,7 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
// Try to initiate second rotation - should fail
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, "another-password")
require.Error(t, err)
assert.Contains(t, err.Error(), "rotation already pending")
assert.ErrorIs(t, err, fleet.ErrRecoveryLockRotationPending)
})
t.Run("InitiateRecoveryLockRotation rejects pending status", func(t *testing.T) {
@ -11220,7 +11221,7 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
// Try to initiate rotation on pending status - should fail
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, "new-password")
require.Error(t, err)
assert.Contains(t, err.Error(), "not eligible for rotation")
assert.ErrorIs(t, err, fleet.ErrRecoveryLockNotEligible)
})
t.Run("InitiateRecoveryLockRotation allows failed status", func(t *testing.T) {
@ -11390,3 +11391,226 @@ func testRecoveryLockRotation(t *testing.T, ds *Datastore) {
assert.False(t, pending)
})
}
func testRecoveryLockAutoRotation(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Helper to set up a host with a verified recovery lock password
setupHostWithVerifiedPassword := func(t *testing.T, name, uuid string) *fleet.Host {
t.Helper()
host := test.NewHost(t, ds, name, "2.3.4."+uuid[:3], name+"key", uuid, time.Now())
pw := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
require.NoError(t, err)
err = ds.SetRecoveryLockVerified(ctx, host.UUID)
require.NoError(t, err)
return host
}
// Helper to get auto_rotate_at directly from DB
getAutoRotateAt := func(t *testing.T, hostUUID string) *time.Time {
t.Helper()
var autoRotateAt *time.Time
err := ds.writer(ctx).GetContext(ctx, &autoRotateAt, `
SELECT auto_rotate_at FROM host_recovery_key_passwords
WHERE host_uuid = ? AND deleted = 0`, hostUUID)
if err == sql.ErrNoRows {
return nil
}
require.NoError(t, err)
return autoRotateAt
}
t.Run("MarkRecoveryLockPasswordViewed sets auto_rotate_at", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "view-host1", "viewuuid0001")
// Initially no auto_rotate_at
autoRotateAt := getAutoRotateAt(t, host.UUID)
assert.Nil(t, autoRotateAt)
// Mark as viewed
rotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.NoError(t, err)
assert.False(t, rotateAt.IsZero())
// Verify auto_rotate_at is approximately 1 hour from now
expectedRotateAt := time.Now().Add(1 * time.Hour)
assert.WithinDuration(t, expectedRotateAt, rotateAt, 1*time.Minute)
// Verify via direct DB query
autoRotateAt = getAutoRotateAt(t, host.UUID)
require.NotNil(t, autoRotateAt)
assert.WithinDuration(t, expectedRotateAt, *autoRotateAt, 1*time.Minute)
})
t.Run("MarkRecoveryLockPasswordViewed updates existing auto_rotate_at", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "view-host2", "viewuuid0002")
// First view
firstRotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond) // Small delay to ensure different timestamp
// Second view should update auto_rotate_at
secondRotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.NoError(t, err)
// Second rotation time should be after first
assert.True(t, secondRotateAt.After(firstRotateAt), "second view should update auto_rotate_at")
// Verify the value was persisted in the database
pw, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, pw.AutoRotateAt, "auto_rotate_at should be persisted")
assert.True(t, pw.AutoRotateAt.After(firstRotateAt), "persisted auto_rotate_at should be after first rotation time")
})
t.Run("MarkRecoveryLockPasswordViewed fails for non-existent host", func(t *testing.T) {
_, err := ds.MarkRecoveryLockPasswordViewed(ctx, "non-existent-uuid")
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
})
t.Run("MarkRecoveryLockPasswordViewed fails for remove operation", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "view-host3", "viewuuid0003")
// Change to remove operation type
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_recovery_key_passwords
SET operation_type = 'remove'
WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
// Should fail because operation_type is not 'install'
_, err = ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
})
// Helper to check if a host UUID is in the rotation info list
containsHostUUID := func(hosts []fleet.HostAutoRotationInfo, uuid string) bool {
for _, h := range hosts {
if h.HostUUID == uuid {
return true
}
}
return false
}
t.Run("GetHostsForAutoRotation returns due hosts", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "auto-rotate-host1", "autorotateuuid1")
// Set auto_rotate_at to 2 hours ago (past due)
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_recovery_key_passwords
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
// Should be returned
hosts, err := ds.GetHostsForAutoRotation(ctx)
require.NoError(t, err)
assert.True(t, containsHostUUID(hosts, host.UUID), "host should be in auto-rotation list")
})
t.Run("GetHostsForAutoRotation excludes future auto_rotate_at", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "auto-rotate-host2", "autorotateuuid2")
// Set auto_rotate_at to 1 hour in the future
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_recovery_key_passwords
SET auto_rotate_at = DATE_ADD(NOW(6), INTERVAL 1 HOUR)
WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
// Should NOT be returned
hosts, err := ds.GetHostsForAutoRotation(ctx)
require.NoError(t, err)
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
})
t.Run("GetHostsForAutoRotation excludes hosts with pending rotation", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "auto-rotate-host3", "autorotateuuid3")
// Set auto_rotate_at to past due
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_recovery_key_passwords
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
// Initiate rotation (sets pending_encrypted_password)
newPassword := apple_mdm.GenerateRecoveryLockPassword()
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, newPassword)
require.NoError(t, err)
// Should NOT be returned because pending rotation exists
hosts, err := ds.GetHostsForAutoRotation(ctx)
require.NoError(t, err)
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
})
t.Run("GetHostsForAutoRotation excludes non-verified hosts", func(t *testing.T) {
host := test.NewHost(t, ds, "auto-rotate-host4", "2.3.4.104", "autorotate4key", "autorotateuuid4", time.Now())
pw := apple_mdm.GenerateRecoveryLockPassword()
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
require.NoError(t, err)
// Status is "pending" after SetHostsRecoveryLockPasswords, NOT verified
// Set auto_rotate_at to past due
_, err = ds.writer(ctx).ExecContext(ctx, `
UPDATE host_recovery_key_passwords
SET auto_rotate_at = DATE_SUB(NOW(6), INTERVAL 2 HOUR)
WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
// Should NOT be returned because status is not verified
hosts, err := ds.GetHostsForAutoRotation(ctx)
require.NoError(t, err)
assert.False(t, containsHostUUID(hosts, host.UUID), "host should not be in auto-rotation list")
})
t.Run("CompleteRecoveryLockRotation clears auto_rotate_at", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "complete-auto-rotate", "completeautorot")
// Mark as viewed to set auto_rotate_at
_, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.NoError(t, err)
// Verify auto_rotate_at is set
autoRotateAt := getAutoRotateAt(t, host.UUID)
require.NotNil(t, autoRotateAt)
// Initiate and complete rotation
newPassword := apple_mdm.GenerateRecoveryLockPassword()
err = ds.InitiateRecoveryLockRotation(ctx, host.UUID, newPassword)
require.NoError(t, err)
err = ds.CompleteRecoveryLockRotation(ctx, host.UUID)
require.NoError(t, err)
// auto_rotate_at should be cleared
autoRotateAt = getAutoRotateAt(t, host.UUID)
assert.Nil(t, autoRotateAt)
})
t.Run("GetHostRecoveryLockPassword includes auto_rotate_at", func(t *testing.T) {
host := setupHostWithVerifiedPassword(t, "get-pw-auto-rotate", "getpwautorot")
// Initially no auto_rotate_at
pw, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
assert.Nil(t, pw.AutoRotateAt)
// Mark as viewed
rotateAt, err := ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
require.NoError(t, err)
// Now auto_rotate_at should be returned
pw, err = ds.GetHostRecoveryLockPassword(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, pw.AutoRotateAt)
assert.WithinDuration(t, rotateAt, *pw.AutoRotateAt, 1*time.Second)
})
}

View file

@ -0,0 +1,28 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260326131501, Down_20260326131501)
}
func Up_20260326131501(tx *sql.Tx) error {
// Add auto_rotate_at column to track when a viewed password should be automatically rotated.
// When a password is viewed via the API, auto_rotate_at is set to 1 hour in the future.
// The cron job rotates passwords where auto_rotate_at <= NOW().
if _, err := tx.Exec(`
ALTER TABLE host_recovery_key_passwords
ADD COLUMN auto_rotate_at TIMESTAMP(6) NULL DEFAULT NULL,
ADD INDEX idx_auto_rotate_at (auto_rotate_at)
`); err != nil {
return fmt.Errorf("adding auto_rotate_at column to host_recovery_key_passwords: %w", err)
}
return nil
}
func Down_20260326131501(tx *sql.Tx) error {
return nil
}

File diff suppressed because one or more lines are too long

View file

@ -1004,9 +1004,12 @@ func (a ActivityTypeWipedHost) HostIDs() []uint {
return []uint{a.HostID}
}
// ActivityTypeRotatedHostRecoveryLockPassword is for password rotation.
// Can be user-initiated (manual) or Fleet-initiated (auto-rotation after password viewed).
type ActivityTypeRotatedHostRecoveryLockPassword struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
FleetInitiated bool `json:"-"` // True for auto-rotation, not serialized
}
func (a ActivityTypeRotatedHostRecoveryLockPassword) ActivityName() string {
@ -1017,6 +1020,10 @@ func (a ActivityTypeRotatedHostRecoveryLockPassword) HostIDs() []uint {
return []uint{a.HostID}
}
func (a ActivityTypeRotatedHostRecoveryLockPassword) WasFromAutomation() bool {
return a.FleetInitiated
}
type ActivityTypeCreatedDeclarationProfile struct {
ProfileName string `json:"profile_name"`
Identifier string `json:"identifier"`

View file

@ -18,6 +18,16 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
)
// Sentinel errors for recovery lock rotation
var (
// ErrRecoveryLockRotationPending indicates a rotation is already in progress for the host.
ErrRecoveryLockRotationPending = errors.New("recovery lock rotation already pending")
// ErrRecoveryLockNotEligible indicates the host is not eligible for rotation
// (e.g., wrong status, operation type, or no existing password).
ErrRecoveryLockNotEligible = errors.New("host not eligible for recovery lock rotation")
)
type MDMAppleCommandIssuer interface {
InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error
RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error
@ -1144,8 +1154,9 @@ type HostLocationData struct {
// HostRecoveryLockPassword represents a recovery lock password for a host.
type HostRecoveryLockPassword struct {
Password string
UpdatedAt time.Time
Password string
UpdatedAt time.Time
AutoRotateAt *time.Time // When auto-rotation is scheduled (1 hour after password is viewed)
}
// HostRecoveryLockPasswordPayload contains the data needed to store a recovery lock password.
@ -1163,3 +1174,10 @@ type HostRecoveryLockRotationStatus struct {
HasPendingRotation bool // pending_encrypted_password is not null
PendingErrorMessage *string // error from failed rotation
}
// HostAutoRotationInfo contains the minimal host data needed for auto-rotation activity logging.
type HostAutoRotationInfo struct {
HostUUID string `db:"host_uuid"`
HostID uint `db:"host_id"`
DisplayName string `db:"display_name"`
}

View file

@ -1576,6 +1576,16 @@ type Datastore interface {
// This is used when a clear command fails with a transient error (not password mismatch).
ResetRecoveryLockForRetry(ctx context.Context, hostUUID string) error
// MarkRecoveryLockPasswordViewed sets auto_rotate_at to 1 hour from now.
// Called when the password is viewed and returns the scheduled rotation time.
MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error)
// GetHostsForAutoRotation returns hosts where auto_rotate_at <= now
// and are eligible for rotation (verified status, no pending rotation).
// Returns host info needed for rotation and activity logging.
// Limited to 100 hosts per batch.
GetHostsForAutoRotation(ctx context.Context) ([]HostAutoRotationInfo, error)
// InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the
// database (or S3 if configured).
InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error

View file

@ -1663,6 +1663,7 @@ func ValidateMDMSettingsAppleSupportedOSVersion[T fleet.MDM | fleet.TeamMDM](set
type RecoveryLockCommander interface {
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
ClearRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error
}
// SendRecoveryLockCommands is the cron job function that sends SetRecoveryLock MDM commands
@ -1675,8 +1676,9 @@ func SendRecoveryLockCommands(
ds fleet.Datastore,
commander *MDMAppleCommander,
logger *slog.Logger,
newActivityFn fleet.NewActivityFunc,
) error {
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger)
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger, newActivityFn)
}
func sendRecoveryLockCommandsWithCommander(
@ -1684,6 +1686,7 @@ func sendRecoveryLockCommandsWithCommander(
ds fleet.Datastore,
commander RecoveryLockCommander,
logger *slog.Logger,
newActivityFn fleet.NewActivityFunc,
) error {
var result *multierror.Error
@ -1706,6 +1709,11 @@ func sendRecoveryLockCommandsWithCommander(
result = multierror.Append(result, err)
}
// Handle AUTO-ROTATION for viewed passwords (password viewed 1+ hour ago)
if err := sendAutoRotationCommands(ctx, ds, commander, logger, newActivityFn); err != nil {
result = multierror.Append(result, err)
}
return result.ErrorOrNil()
}
@ -1854,6 +1862,120 @@ func sendClearRecoveryLockCommands(
return nil
}
func sendAutoRotationCommands(
ctx context.Context,
ds fleet.Datastore,
commander RecoveryLockCommander,
logger *slog.Logger,
newActivityFn fleet.NewActivityFunc,
) error {
hosts, err := ds.GetHostsForAutoRotation(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get hosts for auto rotation")
}
if len(hosts) == 0 {
logger.DebugContext(ctx, "no hosts need auto-rotation")
return nil
}
logger.InfoContext(ctx, "performing auto-rotation for viewed passwords", "count", len(hosts))
var result *multierror.Error
for _, host := range hosts {
newPassword := GenerateRecoveryLockPassword()
// Initiate rotation - stores pending password and validates eligibility
if err := ds.InitiateRecoveryLockRotation(ctx, host.HostUUID, newPassword); err != nil {
// Check for benign race conditions where host state changed between
// GetHostsForAutoRotation and now (e.g., manual rotation started,
// password removed, host deleted, etc.)
if fleet.IsNotFound(err) ||
errors.Is(err, fleet.ErrRecoveryLockRotationPending) ||
errors.Is(err, fleet.ErrRecoveryLockNotEligible) {
logger.DebugContext(ctx, "host lost eligibility for auto-rotation",
"host_uuid", host.HostUUID,
"error", err,
)
continue
}
logger.ErrorContext(ctx, "failed to initiate auto-rotation",
"host_uuid", host.HostUUID,
"error", err,
)
result = multierror.Append(result, err)
continue
}
// Enqueue RotateRecoveryLock command
cmdUUID := uuid.NewString()
if err := commander.RotateRecoveryLock(ctx, host.HostUUID, cmdUUID); err != nil {
var apnsErr *APNSDeliveryError
if errors.As(err, &apnsErr) {
// Command was persisted but push notification failed - log activity and continue.
// The command will be retried when the device checks in.
logAutoRotationActivity(ctx, logger, newActivityFn, host)
logger.WarnContext(ctx, "auto-rotation command enqueued but APNs push failed",
"host_uuid", host.HostUUID,
"command_uuid", cmdUUID,
"error", err,
)
continue
}
// Persistence failed - clear pending rotation so host can be retried
logger.ErrorContext(ctx, "failed to enqueue auto-rotation command",
"host_uuid", host.HostUUID,
"error", err,
)
if clearErr := ds.ClearRecoveryLockRotation(ctx, host.HostUUID); clearErr != nil {
logger.ErrorContext(ctx, "failed to clear pending rotation after enqueue failure",
"host_uuid", host.HostUUID,
"error", clearErr,
)
result = multierror.Append(result, clearErr)
}
result = multierror.Append(result, err)
continue
}
// Log activity for auto-rotation (Fleet-initiated)
logAutoRotationActivity(ctx, logger, newActivityFn, host)
logger.DebugContext(ctx, "sent auto-rotation command",
"host_uuid", host.HostUUID,
"command_uuid", cmdUUID,
)
}
return result.ErrorOrNil()
}
// logAutoRotationActivity logs the rotation activity for auto-rotations.
// It uses the same activity type as manual rotations but marks it as Fleet-initiated.
func logAutoRotationActivity(
ctx context.Context,
logger *slog.Logger,
newActivityFn fleet.NewActivityFunc,
host fleet.HostAutoRotationInfo,
) {
if newActivityFn == nil {
return
}
if err := newActivityFn(ctx, nil, fleet.ActivityTypeRotatedHostRecoveryLockPassword{
HostID: host.HostID,
HostDisplayName: host.DisplayName,
FleetInitiated: true,
}); err != nil {
logger.WarnContext(ctx, "auto-rotation: failed to create activity",
"host_uuid", host.HostUUID,
"err", err,
)
}
}
// RecoveryLockPasswordCharset excludes confusing characters (0/O, 1/I/l)
const RecoveryLockPasswordCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"

View file

@ -587,6 +587,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
var commandSent bool
mockCommander := &mockRecoveryLockCommander{
@ -596,7 +600,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
require.NoError(t, err)
assert.False(t, commandSent, "SetRecoveryLock should not be called when no hosts need it")
})
@ -616,6 +620,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
// Track call order to verify correct sequencing
var callOrder []string
@ -636,7 +644,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
require.NoError(t, err)
// Verify call order: password must be stored BEFORE command is sent
@ -665,6 +673,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
// Track call order to verify correct sequencing
var callOrder []string
@ -687,7 +699,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "APNs push failed")
@ -714,6 +726,10 @@ func TestSendRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
// Track call order to verify ClearRecoveryLockPendingStatus is NOT called
var callOrder []string
@ -735,7 +751,7 @@ func TestSendRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
// Should NOT return error - command was persisted, just push failed
require.NoError(t, err)
@ -748,8 +764,9 @@ func TestSendRecoveryLockCommands(t *testing.T) {
// mockRecoveryLockCommander implements RecoveryLockCommander for testing.
type mockRecoveryLockCommander struct {
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
clearRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
clearRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
rotateRecoveryLockFn func(ctx context.Context, hostUUID string, cmdUUID string) error
}
func (m *mockRecoveryLockCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
@ -766,6 +783,13 @@ func (m *mockRecoveryLockCommander) ClearRecoveryLock(ctx context.Context, hostU
return nil
}
func (m *mockRecoveryLockCommander) RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error {
if m.rotateRecoveryLockFn != nil {
return m.rotateRecoveryLockFn(ctx, hostUUID, cmdUUID)
}
return nil
}
func TestSendClearRecoveryLockCommands(t *testing.T) {
ctx := t.Context()
logger := slog.New(slog.DiscardHandler)
@ -787,6 +811,10 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return []string{hostUUID}, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
var clearCalled bool
mockCommander := &mockRecoveryLockCommander{
@ -797,7 +825,7 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
require.NoError(t, err)
require.True(t, clearCalled, "ClearRecoveryLock should have been called")
})
@ -817,6 +845,10 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
ds.ClaimHostsForRecoveryLockClearFunc = func(ctx context.Context) ([]string, error) {
return nil, nil
}
// Mock auto-rotation - no hosts need auto-rotation
ds.GetHostsForAutoRotationFunc = func(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
return nil, nil
}
mockCommander := &mockRecoveryLockCommander{
clearRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
@ -825,7 +857,7 @@ func TestSendClearRecoveryLockCommands(t *testing.T) {
},
}
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger, nil)
require.NoError(t, err)
})
}

View file

@ -1063,6 +1063,10 @@ type HasPendingRecoveryLockRotationFunc func(ctx context.Context, hostUUID strin
type ResetRecoveryLockForRetryFunc func(ctx context.Context, hostUUID string) error
type MarkRecoveryLockPasswordViewedFunc func(ctx context.Context, hostUUID string) (time.Time, error)
type GetHostsForAutoRotationFunc func(ctx context.Context) ([]fleet.HostAutoRotationInfo, 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
@ -3394,6 +3398,12 @@ type DataStore struct {
ResetRecoveryLockForRetryFunc ResetRecoveryLockForRetryFunc
ResetRecoveryLockForRetryFuncInvoked bool
MarkRecoveryLockPasswordViewedFunc MarkRecoveryLockPasswordViewedFunc
MarkRecoveryLockPasswordViewedFuncInvoked bool
GetHostsForAutoRotationFunc GetHostsForAutoRotationFunc
GetHostsForAutoRotationFuncInvoked bool
InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFunc
InsertMDMAppleBootstrapPackageFuncInvoked bool
@ -8192,6 +8202,20 @@ func (s *DataStore) ResetRecoveryLockForRetry(ctx context.Context, hostUUID stri
return s.ResetRecoveryLockForRetryFunc(ctx, hostUUID)
}
func (s *DataStore) MarkRecoveryLockPasswordViewed(ctx context.Context, hostUUID string) (time.Time, error) {
s.mu.Lock()
s.MarkRecoveryLockPasswordViewedFuncInvoked = true
s.mu.Unlock()
return s.MarkRecoveryLockPasswordViewedFunc(ctx, hostUUID)
}
func (s *DataStore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostAutoRotationInfo, error) {
s.mu.Lock()
s.GetHostsForAutoRotationFuncInvoked = true
s.mu.Unlock()
return s.GetHostsForAutoRotationFunc(ctx)
}
func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error {
s.mu.Lock()
s.InsertMDMAppleBootstrapPackageFuncInvoked = true

View file

@ -6609,6 +6609,7 @@ func NewSetRecoveryLockResultsHandler(
if err := ds.CompleteRecoveryLockRotation(ctx, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: complete rotation")
}
logger.InfoContext(ctx, "RotateRecoveryLock acknowledged, password rotated",
"host_uuid", hostUUID,
)

View file

@ -656,7 +656,9 @@ func TestSetRecoveryLockResultsHandler(t *testing.T) {
return "", nil
}
// No activity should be created for manual rotation (activity logged at initiation)
newActivityFn := func(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
t.Fatal("Activity should not be created for manual rotation completion")
return nil
}
@ -764,4 +766,8 @@ func TestSetRecoveryLockResultsHandler(t *testing.T) {
assert.True(t, failRotationCalled, "FailRecoveryLockRotation should be called for command format errors")
assert.Equal(t, "RotateRecoveryLock command failed", capturedError)
})
// Note: Activity logging for auto-rotation now happens at initiation time
// (in the cron job's sendAutoRotationCommands), not at completion time.
// Manual rotation activity is logged at the API handler level.
}

View file

@ -3902,8 +3902,9 @@ type getHostRecoveryLockPasswordRequest struct {
}
type recoveryLockPasswordPayload struct {
Password string `json:"password"`
UpdatedAt time.Time `json:"updated_at"`
Password string `json:"password"`
UpdatedAt time.Time `json:"updated_at"`
AutoRotateAt *time.Time `json:"auto_rotate_at,omitempty"`
}
type getHostRecoveryLockPasswordResponse struct {
@ -3923,8 +3924,9 @@ func getHostRecoveryLockPasswordEndpoint(ctx context.Context, request any, svc f
return getHostRecoveryLockPasswordResponse{
HostID: req.ID,
RecoveryLockPassword: &recoveryLockPasswordPayload{
Password: password.Password,
UpdatedAt: password.UpdatedAt,
Password: password.Password,
UpdatedAt: password.UpdatedAt,
AutoRotateAt: password.AutoRotateAt,
},
}, nil
}
@ -3966,6 +3968,8 @@ func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint
return nil, ctxerr.Wrap(ctx, err, "get host recovery lock password")
}
// Create activity first. If this fails, we return an error before scheduling
// rotation, ensuring we don't rotate passwords that were never returned to the user.
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
@ -3977,6 +3981,14 @@ func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint
return nil, ctxerr.Wrap(ctx, err, "create activity for viewed host recovery lock password")
}
// Schedule auto-rotation by marking the password as viewed.
// This sets auto_rotate_at to 1 hour from now.
rotateAt, err := svc.ds.MarkRecoveryLockPasswordViewed(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "mark recovery lock password viewed")
}
password.AutoRotateAt = &rotateAt
return password, nil
}

View file

@ -4734,10 +4734,102 @@ func TestGetHostRecoveryLockPassword(t *testing.T) {
Password: "test-password",
}, nil
}
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
return time.Now().Add(1 * time.Hour), nil
}
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
return nil
}
userCtx := test.UserContext(ctx, test.UserAdmin)
password, err := svc.GetHostRecoveryLockPassword(userCtx, 3)
require.NoError(t, err)
assert.Equal(t, "test-password", password.Password)
})
t.Run("calls MarkRecoveryLockPasswordViewed and sets auto_rotate_at", func(t *testing.T) {
ds := new(mock.Store)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
appleSiliconHost := &fleet.Host{
ID: 4,
Platform: "darwin",
UUID: "apple-silicon-uuid-4",
CPUType: "arm64e",
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return appleSiliconHost, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
MDM: fleet.MDM{
EnabledAndConfigured: true,
},
}, nil
}
ds.GetHostRecoveryLockPasswordFunc = func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
return &fleet.HostRecoveryLockPassword{
Password: "test-password-4",
}, nil
}
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, _ activity_api.ActivityDetails) error {
return nil
}
expectedRotateAt := time.Now().Add(1 * time.Hour)
markViewedCalled := false
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
markViewedCalled = true
assert.Equal(t, "apple-silicon-uuid-4", hostUUID)
return expectedRotateAt, nil
}
userCtx := test.UserContext(ctx, test.UserAdmin)
password, err := svc.GetHostRecoveryLockPassword(userCtx, 4)
require.NoError(t, err)
assert.Equal(t, "test-password-4", password.Password)
assert.True(t, markViewedCalled, "MarkRecoveryLockPasswordViewed should be called")
require.NotNil(t, password.AutoRotateAt)
assert.WithinDuration(t, expectedRotateAt, *password.AutoRotateAt, 1*time.Second)
})
t.Run("fails if MarkRecoveryLockPasswordViewed fails", func(t *testing.T) {
ds := new(mock.Store)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
appleSiliconHost := &fleet.Host{
ID: 5,
Platform: "darwin",
UUID: "apple-silicon-uuid-5",
CPUType: "arm64e",
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return appleSiliconHost, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
MDM: fleet.MDM{
EnabledAndConfigured: true,
},
}, nil
}
ds.GetHostRecoveryLockPasswordFunc = func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
return &fleet.HostRecoveryLockPassword{
Password: "test-password-5",
}, nil
}
ds.MarkRecoveryLockPasswordViewedFunc = func(ctx context.Context, hostUUID string) (time.Time, error) {
return time.Time{}, errors.New("database error")
}
userCtx := test.UserContext(ctx, test.UserAdmin)
// Should fail because rotation scheduling failed - password must not be
// returned unless rotation is successfully scheduled
password, err := svc.GetHostRecoveryLockPassword(userCtx, 5)
require.Error(t, err)
assert.Nil(t, password)
assert.Contains(t, err.Error(), "mark recovery lock password viewed")
})
}

View file

@ -22355,7 +22355,7 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
runRecoveryLockCron := func(t *testing.T) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
err := apple_mdm.SendRecoveryLockCommands(t.Context(), s.ds, s.mdmCommander, logger)
err := apple_mdm.SendRecoveryLockCommands(t.Context(), s.ds, s.mdmCommander, logger, s.fleetSvc.NewActivity)
require.NoError(t, err)
}
@ -22568,7 +22568,7 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
assert.NotEqual(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password, "password should be different after rotation")
// Verify activity was created
// Verify activity was created for user-triggered rotation
s.lastActivityOfTypeMatches(fleet.ActivityTypeRotatedHostRecoveryLockPassword{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
@ -22939,6 +22939,109 @@ func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() {
}, http.StatusOK, &appConfigResponse{})
})
// =========================================================================
// Test 11: Viewing password sets auto_rotate_at
// =========================================================================
t.Run("Viewing password sets auto_rotate_at", func(t *testing.T) {
// Enable recovery lock password
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
"mdm": map[string]any{"enable_recovery_lock_password": true},
}, http.StatusOK, &appConfigResponse{})
host, mdmClient := createAppleSiliconHost(t)
// Run cron and acknowledge command to get to verified state
runRecoveryLockCron(t)
cmd, err := mdmClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// View the password
var getPasswordResp getHostRecoveryLockPasswordResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
assert.NotEmpty(t, getPasswordResp.RecoveryLockPassword.Password)
// auto_rotate_at should be set (approximately 1 hour from now)
require.NotNil(t, getPasswordResp.RecoveryLockPassword.AutoRotateAt, "auto_rotate_at should be set after viewing password")
expectedRotateAt := time.Now().Add(1 * time.Hour)
assert.WithinDuration(t, expectedRotateAt, *getPasswordResp.RecoveryLockPassword.AutoRotateAt, 2*time.Minute)
// Disable recovery lock password
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
"mdm": map[string]any{"enable_recovery_lock_password": false},
}, http.StatusOK, &appConfigResponse{})
})
// =========================================================================
// Test 12: Auto-rotation triggers after password is viewed
// =========================================================================
t.Run("Auto-rotation triggers after password is viewed", func(t *testing.T) {
// Enable recovery lock password
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
"mdm": map[string]any{"enable_recovery_lock_password": true},
}, http.StatusOK, &appConfigResponse{})
host, mdmClient := createAppleSiliconHost(t)
// Run cron and acknowledge command to get to verified state
runRecoveryLockCron(t)
cmd, err := mdmClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// Get the original password
var getPasswordResp getHostRecoveryLockPasswordResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
originalPassword := getPasswordResp.RecoveryLockPassword.Password
// Manually set auto_rotate_at to the past to trigger auto-rotation
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(t.Context(),
`UPDATE host_recovery_key_passwords SET auto_rotate_at = ? WHERE host_uuid = ?`,
time.Now().Add(-1*time.Hour), host.UUID)
return err
})
// Run the cron job - should trigger auto-rotation
runRecoveryLockCron(t)
// Host should receive SetRecoveryLock command for rotation
// (rotation uses the same MDM command type as initial setup, but with a new password)
cmd, err = mdmClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "should receive rotation command")
assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType)
// Acknowledge the rotation command
_, err = mdmClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// Password should now be different (rotated)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp)
require.NotNil(t, getPasswordResp.RecoveryLockPassword)
assert.NotEqual(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password, "password should be different after auto-rotation")
// auto_rotate_at should be set again (viewing the password schedules another rotation)
require.NotNil(t, getPasswordResp.RecoveryLockPassword.AutoRotateAt, "auto_rotate_at should be set after viewing rotated password")
expectedRotateAt := time.Now().Add(1 * time.Hour)
assert.WithinDuration(t, expectedRotateAt, *getPasswordResp.RecoveryLockPassword.AutoRotateAt, 2*time.Minute)
// Verify auto-rotation activity was created (Fleet-initiated, uses same activity type as manual rotation)
s.lastActivityOfTypeMatches(fleet.ActivityTypeRotatedHostRecoveryLockPassword{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
// Disable recovery lock password
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
"mdm": map[string]any{"enable_recovery_lock_password": false},
}, http.StatusOK, &appConfigResponse{})
})
// Final cleanup: ensure recovery lock is disabled
s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{
"mdm": map[string]any{"enable_recovery_lock_password": false},