From 05bc7be89e3e240d609bf8e2e75fa4527b141505 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:15:20 -0500 Subject: [PATCH] Add Windows managed local admin account support to fleetd (#50088) **Related issue:** Resolves #48723 # 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] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified that fleetd runs on macOS, Linux and Windows (did not do macOS, but should be the same as Linux) - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) ## Summary by CodeRabbit * **New Features** * Added support for Fleet-managed local administrator accounts on Windows. * When enabled, creates or updates a hidden `_fleetadmin` account, securely generates a password, and escrows it to Fleet. * Reports provisioning errors and supports safe retries without blocking other configuration updates. --- client/orbit_client.go | 15 + .../48723-windows-managed-local-account | 1 + orbit/cmd/orbit/orbit.go | 5 + orbit/pkg/managedaccount/managedaccount.go | 134 ++++++++ .../pkg/managedaccount/managedaccount_stub.go | 10 + .../pkg/managedaccount/managedaccount_test.go | 224 +++++++++++++ .../managedaccount/managedaccount_windows.go | 294 ++++++++++++++++++ server/fleet/apple_mdm.go | 5 - server/fleet/managed_local_account.go | 94 ++++++ server/fleet/managed_local_account_test.go | 55 ++++ server/mdm/apple/apple_mdm.go | 2 +- server/mdm/apple/util.go | 24 -- server/mdm/apple/util_test.go | 19 -- server/worker/apple_mdm.go | 2 +- 14 files changed, 834 insertions(+), 50 deletions(-) create mode 100644 orbit/changes/48723-windows-managed-local-account create mode 100644 orbit/pkg/managedaccount/managedaccount.go create mode 100644 orbit/pkg/managedaccount/managedaccount_stub.go create mode 100644 orbit/pkg/managedaccount/managedaccount_test.go create mode 100644 orbit/pkg/managedaccount/managedaccount_windows.go create mode 100644 server/fleet/managed_local_account.go create mode 100644 server/fleet/managed_local_account_test.go diff --git a/client/orbit_client.go b/client/orbit_client.go index 2f8d48ed50..28a2fe3c9e 100644 --- a/client/orbit_client.go +++ b/client/orbit_client.go @@ -910,6 +910,21 @@ func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error { return nil } +// SendManagedLocalAccountPassword escrows the password of the managed local admin account that fleetd created on this +// Windows host. A non-empty clientError reports that creating the account failed, which the server records against the +// host and which makes it ask this host to try again. +func (oc *OrbitClient) SendManagedLocalAccountPassword(password, clientError string) error { + verb, path := "POST", "/api/fleet/orbit/managed_local_account" + var resp fleet.OrbitPostManagedLocalAccountResponse + if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostManagedLocalAccountRequest{ + Password: password, + ClientError: clientError, + }, &resp); err != nil { + return err + } + return nil +} + func (oc *OrbitClient) InitiateSetupExperience() (fleet.SetupExperienceInitResult, error) { verb, path := "POST", "/api/fleet/orbit/setup_experience/init" var resp fleet.OrbitSetupExperienceInitResponse diff --git a/orbit/changes/48723-windows-managed-local-account b/orbit/changes/48723-windows-managed-local-account new file mode 100644 index 0000000000..25fe30d0a0 --- /dev/null +++ b/orbit/changes/48723-windows-managed-local-account @@ -0,0 +1 @@ +- Added Windows support for the Fleet-managed local admin account: when the setting is enabled for the host's fleet, fleetd creates the hidden `_fleetadmin` administrator account, keeps it off the sign-in screen, and escrows its password to Fleet. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index c1d515de86..b2b373af44 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -43,6 +43,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/keystore" "github.com/fleetdm/fleet/v4/orbit/pkg/logging" "github.com/fleetdm/fleet/v4/orbit/pkg/luks" + "github.com/fleetdm/fleet/v4/orbit/pkg/managedaccount" "github.com/fleetdm/fleet/v4/orbit/pkg/osquery" "github.com/fleetdm/fleet/v4/orbit/pkg/osservice" "github.com/fleetdm/fleet/v4/orbit/pkg/platform" @@ -1235,6 +1236,9 @@ func orbitAction(c *cli.Context) error { // windowsMDMSyncCommandFrequency throttles on-demand OMA-DM syncs: while a command stays queued the server keeps setting // WindowsMDMSyncRequest on each config poll, and this bounds how often we act on it. windowsMDMSyncCommandFrequency = time.Minute + // windowsManagedAccountRetryFrequency paces retries when the managed local account cannot be + // provisioned, for instance because the host's password policy rejects the generated password. + windowsManagedAccountRetryFrequency = time.Hour ) scriptConfigReceiver, scriptsEnabledFn := update.ApplyRunScriptsConfigFetcherMiddleware( @@ -1314,6 +1318,7 @@ func orbitAction(c *cli.Context) error { defer comWorker.Close() orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMBitlockerFetcherMiddleware( windowsMDMBitlockerCommandFrequency, orbitClient, comWorker)) + orbitClient.RegisterConfigReceiver(managedaccount.New(orbitClient, windowsManagedAccountRetryFrequency)) case "linux": orbitClient.RegisterConfigReceiver(luks.New(orbitClient)) } diff --git a/orbit/pkg/managedaccount/managedaccount.go b/orbit/pkg/managedaccount/managedaccount.go new file mode 100644 index 0000000000..4e15edbdb6 --- /dev/null +++ b/orbit/pkg/managedaccount/managedaccount.go @@ -0,0 +1,134 @@ +// Package managedaccount creates and maintains the Fleet-managed local admin account on Windows hosts, and escrows its +// password to the Fleet server. +// +// The server asks for the account by setting the CreateWindowsManagedLocalAccount notification on the orbit config +// response, and stops asking once this host escrows a password for its current MDM enrollment. Every step is idempotent, +// so being asked again is always safe. +package managedaccount + +import ( + "sync" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" +) + +// Escrower sends the managed local account password to the Fleet server. +type Escrower interface { + SendManagedLocalAccountPassword(password, clientError string) error +} + +// provisionFunc creates or updates the managed local admin account and hides it from the sign-in screen. +type provisionFunc func(username, password string) error + +// Receiver reacts to the CreateWindowsManagedLocalAccount notification. +type Receiver struct { + escrower Escrower + + // provision is indirected so tests can exercise the flow without touching Windows APIs. nil means + // use the platform implementation. + provision provisionFunc + + // retryFrequency is the minimum time between attempts after a failure. The success path needs no throttle because + // the server stops sending the notification once a password is escrowed, so this only ever paces a host that fails. + retryFrequency time.Duration + + // mu keeps a single provisioning attempt in flight. Held for the duration of the background + // goroutine, so a notification that arrives again while work is running is dropped rather than + // starting a second account reset. It also guards lastFailure. + mu sync.Mutex + + // lastFailure is when the most recent attempt failed, zero after a success. + lastFailure time.Time +} + +// New returns a Receiver that escrows through the given Escrower, retrying at most once every +// retryFrequency after a failure. +func New(escrower Escrower, retryFrequency time.Duration) *Receiver { + return &Receiver{escrower: escrower, retryFrequency: retryFrequency} +} + +// Run implements fleet.OrbitConfigReceiver. It returns immediately; provisioning happens in the +// background so a slow Windows API call or HTTP request never gates the config receiver loop. +func (r *Receiver) Run(cfg *fleet.OrbitConfig) error { + if cfg == nil || !cfg.Notifications.CreateWindowsManagedLocalAccount { + return nil + } + r.attempt() + return nil +} + +// attempt starts provisioning in the background. The returned channel is closed once the attempt has +// finished and released the single-flight lock, or nil when another attempt was already running. +// Run discards it; it exists so callers that need to know an attempt is fully done, notably tests, +// observe a point where the lock is guaranteed free rather than one merely inside the work. +func (r *Receiver) attempt() <-chan struct{} { + // TryLock rather than Lock: if an attempt is already running, drop this one instead of queueing a second account + // reset behind it. The server keeps asking until an escrow succeeds, so nothing is lost by skipping. + if !r.mu.TryLock() { + log.Debug().Msg("managed local account: provisioning already in progress, skipping") + return nil + } + // The server re-sends the notification on every config fetch, so without this a host that cannot + // provision would redo the syscalls and re-post its error every 30 seconds, indefinitely. + if !r.lastFailure.IsZero() && time.Since(r.lastFailure) <= r.retryFrequency { + log.Debug().Msg("managed local account: last attempt failed too recently, skipping") + r.mu.Unlock() + return nil + } + done := make(chan struct{}) + go func() { + // Deferred LIFO, so the mutex is released first, then the panic is contained, then completion is signaled. + defer close(done) + defer func() { + // A panic in a goroutine takes down the whole process, and this one drives raw Windows syscalls. + // Provisioning a local account must not be able to kill orbit/osquery. The next poll retries. + if p := recover(); p != nil { + log.Error().Interface("panic", p).Msg("managed local account: recovered from panic while provisioning") + } + }() + defer r.mu.Unlock() + + // Assume failure, so an early return or a panic still paces the next attempt; cleared on success. + // Both writes happen while the lock is held. + r.lastFailure = time.Now() + if r.createAndEscrow() { + r.lastFailure = time.Time{} // clear time + } + }() + return done +} + +// createAndEscrow generates a password, provisions the account, and escrows the password. Any failure before the escrow +// returns without recording success, so the next config fetch retries the whole flow; the provisioning step resets the +// password of an existing account, which is what makes that retry safe. +// It reports whether the password was successfully escrowed. +func (r *Receiver) createAndEscrow() bool { + password := fleet.GenerateManagedLocalAccountPassword(true) + + provision := r.provision + if provision == nil { + provision = provisionAccount + } + + if err := provision(fleet.ManagedLocalAccountUsername, password); err != nil { + log.Error().Err(err).Msg("managed local account: creating account") + // Tell the server why, so it surfaces on the host instead of only in this log. The server + // records the failure and keeps asking, so this is a report, not a terminal state. + if escrowErr := r.escrower.SendManagedLocalAccountPassword("", err.Error()); escrowErr != nil { + log.Error().Err(escrowErr).Msg("managed local account: reporting creation failure") + } + return false + } + + if err := r.escrower.SendManagedLocalAccountPassword(password, ""); err != nil { + // The account now exists with a password Fleet does not know. That is recovered by the next + // notification: provisioning resets the password and escrows the new one. + log.Error().Err(err).Msg("managed local account: escrowing password") + return false + } + + log.Info().Msg("managed local account: account created; password escrowed") + return true +} diff --git a/orbit/pkg/managedaccount/managedaccount_stub.go b/orbit/pkg/managedaccount/managedaccount_stub.go new file mode 100644 index 0000000000..ce7e840311 --- /dev/null +++ b/orbit/pkg/managedaccount/managedaccount_stub.go @@ -0,0 +1,10 @@ +//go:build !windows + +package managedaccount + +import "errors" + +// provisionAccount is a placeholder for non-Windows builds. +func provisionAccount(username, password string) error { + return errors.New("managed local account provisioning is only supported on Windows") +} diff --git a/orbit/pkg/managedaccount/managedaccount_test.go b/orbit/pkg/managedaccount/managedaccount_test.go new file mode 100644 index 0000000000..8174bf19db --- /dev/null +++ b/orbit/pkg/managedaccount/managedaccount_test.go @@ -0,0 +1,224 @@ +package managedaccount + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockEscrower struct { + mu sync.Mutex + calls int + password string + clientError string + err error +} + +func (m *mockEscrower) SendManagedLocalAccountPassword(password, clientError string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls++ + m.password = password + m.clientError = clientError + return m.err +} + +func (m *mockEscrower) snapshot() (calls int, password, clientError string) { + m.mu.Lock() + defer m.mu.Unlock() + return m.calls, m.password, m.clientError +} + +// newTestReceiver returns a receiver whose provisioning is stubbed. Tests wait on the channel +// attempt() returns, which closes only after the single-flight lock is released. +func newTestReceiver(escrower Escrower, provision provisionFunc) *Receiver { + return &Receiver{escrower: escrower, provision: provision} +} + +// awaitAttempt starts an attempt and waits for it to finish, failing rather than hanging if the +// attempt was dropped or never completes. +func awaitAttempt(t *testing.T, r *Receiver) { + t.Helper() + done := r.attempt() + require.NotNil(t, done, "attempt was dropped") + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("provisioning attempt did not finish") + } +} + +func notification(enabled bool) *fleet.OrbitConfig { + return &fleet.OrbitConfig{ + Notifications: fleet.OrbitConfigNotifications{CreateWindowsManagedLocalAccount: enabled}, + } +} + +func TestReceiverRun(t *testing.T) { + // Run must gate on the notification, and must start provisioning when it is set. Everything below + // exercises attempt() directly so it can wait on completion. + t.Run("Run gates on the notification", func(t *testing.T) { + esc := &mockEscrower{} + provisioned := make(chan struct{}) + r := newTestReceiver(esc, func(string, string) error { + close(provisioned) + return nil + }) + + // A receiver loop that hands over no config at all must not take the process down with it. + require.NoError(t, r.Run(nil)) + require.NoError(t, r.Run(notification(false))) + select { + case <-provisioned: + t.Fatal("provisioning ran without the notification") + case <-time.After(100 * time.Millisecond): + } + + require.NoError(t, r.Run(notification(true))) + select { + case <-provisioned: + case <-time.After(10 * time.Second): + t.Fatal("the notification did not start provisioning") + } + }) + + t.Run("provisions and escrows the password it generated", func(t *testing.T) { + esc := &mockEscrower{} + var gotUser, gotPassword string + r := newTestReceiver(esc, func(username, password string) error { + gotUser, gotPassword = username, password + return nil + }) + + awaitAttempt(t, r) + + assert.Equal(t, fleet.ManagedLocalAccountUsername, gotUser) + calls, escrowed, clientError := esc.snapshot() + assert.Equal(t, 1, calls) + assert.Empty(t, clientError) + assert.NotEmpty(t, gotPassword) + assert.Equal(t, gotPassword, escrowed) + }) + + // A creation failure is reported rather than swallowed, so it surfaces on the host and the server + // keeps asking. No password is escrowed, because none was successfully set. + t.Run("reports a provisioning failure as a client error", func(t *testing.T) { + esc := &mockEscrower{} + r := newTestReceiver(esc, func(string, string) error { + return errors.New("NetUserAdd failed: access denied") + }) + + awaitAttempt(t, r) + + calls, password, clientError := esc.snapshot() + assert.Equal(t, 1, calls) + assert.Empty(t, password) + assert.Contains(t, clientError, "NetUserAdd failed: access denied") + }) + + // The account exists with a password Fleet does not know. Nothing local records success, so the flow + // re-runs and resets the password once the retry window has passed. A failed escrow arms the same + // throttle as a failed creation, since neither got a password safely to the server. + t.Run("a failed escrow leaves nothing that would block a retry", func(t *testing.T) { + esc := &mockEscrower{err: errors.New("server unavailable")} + var provisions int + provision := func(string, string) error { + provisions++ + return nil + } + + r := newTestReceiver(esc, provision) + // No throttle, so pacing cannot mask the retry this is looking for. + r.retryFrequency = 0 + awaitAttempt(t, r) + awaitAttempt(t, r) + + assert.Equal(t, 2, provisions, "the second notification must re-run provisioning") + }) + + t.Run("a panic while provisioning does not escape the goroutine", func(t *testing.T) { + esc := &mockEscrower{} + r := newTestReceiver(esc, func(string, string) error { + panic("simulated syscall failure") + }) + + awaitAttempt(t, r) + + calls, _, _ := esc.snapshot() + assert.Zero(t, calls, "nothing should be escrowed when provisioning panics") + + // The lock was released, so a later notification is still acted on. + var provisioned bool + r.provision = func(string, string) error { + provisioned = true + return nil + } + awaitAttempt(t, r) + assert.True(t, provisioned, "a panic must not wedge the single-flight lock") + }) + + // The server re-sends the notification every config fetch, so a host that cannot provision would + // otherwise redo the syscalls and re-post its error every 30 seconds forever. + t.Run("a failed attempt is not retried until the retry frequency elapses", func(t *testing.T) { + esc := &mockEscrower{} + var provisions int + r := newTestReceiver(esc, func(string, string) error { + provisions++ + return errors.New("policy rejected the password") + }) + r.retryFrequency = time.Hour + + awaitAttempt(t, r) + assert.Equal(t, 1, provisions) + + // A notification arriving right after the failure is dropped rather than redoing the work. + assert.Nil(t, r.attempt(), "a retry inside the frequency window must be dropped") + assert.Equal(t, 1, provisions) + + // Once the window has passed, the host tries again. + r.lastFailure = time.Now().Add(-2 * time.Hour) + awaitAttempt(t, r) + assert.Equal(t, 2, provisions) + }) + + t.Run("a success clears the retry throttle", func(t *testing.T) { + r := newTestReceiver(&mockEscrower{}, func(string, string) error { return nil }) + r.retryFrequency = time.Hour + + awaitAttempt(t, r) + // Make sure the 2nd back-to-back attempt is not dropped after a success. + done := r.attempt() + require.NotNil(t, done, "a success must not arm the retry throttle") + <-done + }) + + t.Run("only one attempt runs at a time", func(t *testing.T) { + esc := &mockEscrower{} + started := make(chan struct{}) + release := make(chan struct{}) + + r := newTestReceiver(esc, func(string, string) error { + close(started) + <-release + return nil + }) + + done := r.attempt() + require.NotNil(t, done) + <-started + + // A second attempt while the first is in flight is dropped, not queued. + assert.Nil(t, r.attempt(), "a concurrent attempt must be dropped") + + close(release) + <-done + + calls, _, _ := esc.snapshot() + assert.Equal(t, 1, calls, "the dropped attempt must not escrow a second password") + }) +} diff --git a/orbit/pkg/managedaccount/managedaccount_windows.go b/orbit/pkg/managedaccount/managedaccount_windows.go new file mode 100644 index 0000000000..b1e5439a9b --- /dev/null +++ b/orbit/pkg/managedaccount/managedaccount_windows.go @@ -0,0 +1,294 @@ +//go:build windows + +package managedaccount + +import ( + "errors" + "fmt" + "unsafe" + + "github.com/rs/zerolog/log" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// Windows account flags (lmaccess.h). UF_DONT_EXPIRE_PASSWD keeps the account usable as a +// break-glass login: Fleet owns the password lifecycle, so Windows must not expire it underneath us. +const ( + ufScript = 0x0001 + ufNormalAccount = 0x0200 + ufDontExpirePasswd = 0x10000 + ufAccountDisable = 0x0002 // UF_ACCOUNTDISABLE: the account exists but cannot log in. + ufLockout = 0x0010 // UF_LOCKOUT: locked out by failed logons. Can be cleared, not set. + + usePrivUser = 1 // USER_PRIV_USER: a plain user; group membership grants admin rights. + + nerrUserNotFound = 2221 // NERR_UserNotFound + errorMemberInAlias = 1378 // ERROR_MEMBER_IN_ALIAS: already a group member. + userInfoPasswordOnly = 1003 // USER_INFO_1003: password-only update level. + userInfoFlagsOnly = 1008 // USER_INFO_1008: flags-only update level. + + // NERR_PasswordTooShort is the catch-all Windows returns for any password-policy rejection, not just length: MSDN + // lists it for "too long, too recent in its change history, not enough unique characters, or does not meet another + // password policy requirement", which includes a custom password filter DLL. + nerrPasswordTooShort = 2245 + // ERROR_PASSWORD_RESTRICTION, the equivalent from the system error range. + errorPasswordRestriction = 1325 +) + +// logonUIHiddenAccountsKey holds a DWORD per account name; 0 hides the account from the sign-in +// screen and from Settings > Accounts. There is no MDM CSP for this, which is why fleetd does it. +const logonUIHiddenAccountsKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList` + +// fleetAccountComment is written as the account's description at creation: it is how a later run tells an account Fleet +// created from an unrelated account that happens to share the name. Do not change this string. +const fleetAccountComment = "Fleet-managed local administrator account." + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserAdd = netapi32.NewProc("NetUserAdd") + procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") + procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") + procNetLocalGroupAddMbrs = netapi32.NewProc("NetLocalGroupAddMembers") + procNetAPIBufferFree = netapi32.NewProc("NetApiBufferFree") +) + +// userInfo1 mirrors USER_INFO_1 (lmaccess.h), the level-1 structure NetUserAdd takes. +type userInfo1 struct { + Name *uint16 + Password *uint16 + PasswordAge uint32 + Priv uint32 + HomeDir *uint16 + Comment *uint16 + Flags uint32 + ScriptPath *uint16 +} + +// userInfo1003 mirrors USER_INFO_1003, which sets only the password. +type userInfo1003 struct { + Password *uint16 +} + +// userInfo1008 mirrors USER_INFO_1008, which sets only the account flags. +type userInfo1008 struct { + Flags uint32 +} + +// localGroupMembersInfo3 mirrors LOCALGROUP_MEMBERS_INFO_3, which identifies a member by name rather than SID. +type localGroupMembersInfo3 struct { + DomainAndName *uint16 +} + +// provisionAccount creates the managed local admin account if it is missing, resets its password if it already exists, +// ensures it is a member of the local Administrators group, and hides it from the sign-in screen. Every step is +// idempotent so the whole function is safe to re-run, which is what makes retrying after a failed escrow safe. +func provisionAccount(username, password string) error { + if err := ensureUser(username, password); err != nil { + return err + } + if err := addToAdministrators(username); err != nil { + return err + } + return hideFromSignInScreen(username) +} + +// ensureUser creates the account, or resets its password when it already exists. The reset branch is what lets a retry +// recover an account whose password Fleet never successfully escrowed. +func ensureUser(username, password string) error { + namePtr, err := windows.UTF16PtrFromString(username) + if err != nil { + return fmt.Errorf("converting username: %w", err) + } + passwordPtr, err := windows.UTF16PtrFromString(password) + if err != nil { + return fmt.Errorf("converting password: %w", err) + } + + existing, err := lookupUser(namePtr) + if err != nil { + return err + } + + if existing != nil { + // Only adopt an account Fleet created. Anything else with this name belongs to someone else, and resetting its + // password and elevating it would be destructive; report it instead so it surfaces on the host rather than + // silently changing an account we do not own. + if existing.comment != fleetAccountComment { + return fmt.Errorf( + "an account named %s already exists and was not created by Fleet, refusing to take it over", username) + } + + info := userInfo1003{Password: passwordPtr} + ret, _, _ := procNetUserSetInfo.Call( + 0, // servername: NULL means the local machine + uintptr(unsafe.Pointer(namePtr)), + userInfoPasswordOnly, + uintptr(unsafe.Pointer(&info)), + 0, // parm_err + ) + if ret != 0 { + return accountError(fmt.Sprintf("resetting password for %s", username), ret, len(password)) + } + // Resetting the password is not enough to make the account usable again. If it was disabled, locked out, or had + // its never-expire flag removed after we created it, Fleet would escrow a password that cannot actually log in. + // Only the flags we own are touched, so anything else set on the account is preserved. + return normalizeUserFlags(namePtr, username, existing.flags) + } + + comment, err := windows.UTF16PtrFromString(fleetAccountComment) + if err != nil { + return fmt.Errorf("converting comment: %w", err) + } + info := userInfo1{ + Name: namePtr, + Password: passwordPtr, + Priv: usePrivUser, + Comment: comment, + Flags: ufScript | ufNormalAccount | ufDontExpirePasswd, + } + ret, _, _ := procNetUserAdd.Call( + 0, // servername + 1, // level + uintptr(unsafe.Pointer(&info)), + 0, // parm_err + ) + if ret != 0 { + return accountError(fmt.Sprintf("creating %s", username), ret, len(password)) + } + return nil +} + +// accountError turns a netapi32 return code into an error, spelling out password-policy rejections. +// Windows reports every one of those as NERR_PasswordTooShort, whose text lives in netmsg.dll rather +// than the system message table, so Go cannot format it and the admin would otherwise see the reason +// their break-glass account never appeared as a bare "winapi error #2245" in Fleet. +func accountError(op string, ret uintptr, passwordLen int) error { + if ret == nerrPasswordTooShort || ret == errorPasswordRestriction { + return fmt.Errorf( + "%s: this device's password policy rejected the generated %d-character password; "+ + "check the minimum password length and any custom password filter on the host", + op, passwordLen) + } + return fmt.Errorf("%s: %w", op, windows.Errno(ret)) +} + +// existingAccount is the subset of USER_INFO_1 the caller needs: the flags say whether a present +// account is actually usable, and the comment says whether it is ours to manage. +type existingAccount struct { + flags uint32 + comment string +} + +// lookupUser returns the account, or nil when no account with that name exists. +func lookupUser(namePtr *uint16) (*existingAccount, error) { + // buf is a real pointer rather than a uintptr so the garbage collector tracks the buffer netapi32 + // allocates for us; converting a uintptr back into a pointer is not safe. + var buf *byte + ret, _, _ := procNetUserGetInfo.Call( + 0, // servername + uintptr(unsafe.Pointer(namePtr)), + 1, // level 1: USER_INFO_1, which carries Flags + uintptr(unsafe.Pointer(&buf)), + ) + switch ret { + case 0: + if buf == nil { + return nil, errors.New("looking up account: NetUserGetInfo returned no data") + } + //nolint:errcheck // freeing the buffer cannot meaningfully fail here + defer procNetAPIBufferFree.Call(uintptr(unsafe.Pointer(buf))) + info := (*userInfo1)(unsafe.Pointer(buf)) + return &existingAccount{ + flags: info.Flags, + comment: windows.UTF16PtrToString(info.Comment), + }, nil + case nerrUserNotFound: + return nil, nil + default: + return nil, fmt.Errorf("looking up account: %w", windows.Errno(ret)) + } +} + +// normalizeUserFlags re-applies the flags Fleet depends on to an account that already existed: +// enabled, not locked out, and password never expires. Other flags are left untouched. +func normalizeUserFlags(namePtr *uint16, username string, current uint32) error { + desired := (current &^ (ufAccountDisable | ufLockout)) | ufDontExpirePasswd + if desired == current { + return nil + } + + info := userInfo1008{Flags: desired} + ret, _, _ := procNetUserSetInfo.Call( + 0, // servername + uintptr(unsafe.Pointer(namePtr)), + userInfoFlagsOnly, + uintptr(unsafe.Pointer(&info)), + 0, // parm_err + ) + if ret != 0 { + return fmt.Errorf("restoring account flags for %s: %w", username, windows.Errno(ret)) + } + log.Debug().Str("username", username).Uint32("from", current).Uint32("to", desired). + Msg("managed local account: restored account flags") + return nil +} + +// addToAdministrators adds the account to the local Administrators group. The group name is resolved +// from its well-known SID rather than hardcoded, because it is localized on non-English Windows. +func addToAdministrators(username string) error { + groupName, err := administratorsGroupName() + if err != nil { + return err + } + groupPtr, err := windows.UTF16PtrFromString(groupName) + if err != nil { + return fmt.Errorf("converting group name: %w", err) + } + memberPtr, err := windows.UTF16PtrFromString(username) + if err != nil { + return fmt.Errorf("converting member name: %w", err) + } + + member := localGroupMembersInfo3{DomainAndName: memberPtr} + ret, _, _ := procNetLocalGroupAddMbrs.Call( + 0, // servername + uintptr(unsafe.Pointer(groupPtr)), + 3, // level + uintptr(unsafe.Pointer(&member)), + 1, // totalentries + ) + // Already a member is the expected outcome on every run after the first. + if ret != 0 && ret != errorMemberInAlias { + return fmt.Errorf("adding %s to %s: %w", username, groupName, windows.Errno(ret)) + } + return nil +} + +func administratorsGroupName() (string, error) { + sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return "", fmt.Errorf("building Administrators SID: %w", err) + } + account, _, _, err := sid.LookupAccount("") + if err != nil { + return "", fmt.Errorf("resolving Administrators group name: %w", err) + } + return account, nil +} + +// hideFromSignInScreen keeps the account off the sign-in screen and out of Settings > Accounts. It +// remains fully usable through "Other user" with an explicit username. +func hideFromSignInScreen(username string) error { + key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, logonUIHiddenAccountsKey, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening UserList registry key: %w", err) + } + defer key.Close() + + if err := key.SetDWordValue(username, 0); err != nil { + return fmt.Errorf("hiding %s from the sign-in screen: %w", username, err) + } + log.Debug().Str("username", username).Msg("managed local account: hidden from sign-in screen") + return nil +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index b2abb9cbf3..54253b3250 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -1509,11 +1509,6 @@ const ( SetAutoAdminPasswordCmdName = "SetAutoAdminPassword" ) -// ManagedLocalAccountUsername is the short name Fleet provisions on macOS hosts -// via the AccountConfiguration MDM command when the managed local account -// feature is enabled. -const ManagedLocalAccountUsername = "_fleetadmin" - // PrimaryAccountType represents the type of the primary account for MacOS going through setup experience. // Documented at https://developer.apple.com/documentation/devicemanagement/accountconfigurationcommand/command-data.dictionary // if `SetPrimarySetupAccountAsRegularUser` or `SkipPrimarySetupAccountCreation` is true, you must configure a local admin account. diff --git a/server/fleet/managed_local_account.go b/server/fleet/managed_local_account.go new file mode 100644 index 0000000000..faa309ef80 --- /dev/null +++ b/server/fleet/managed_local_account.go @@ -0,0 +1,94 @@ +package fleet + +import ( + "crypto/rand" + "encoding/binary" + "strings" +) + +// ManagedLocalAccountUsername is the short name of the local admin account Fleet provisions when the managed local +// account feature is enabled. macOS creates it via the AccountConfiguration MDM command, Windows fleetd creates it directly. +const ManagedLocalAccountUsername = "_fleetadmin" + +const ( + // managedAccountPasswordGroupCount is the number of character groups in a managed account password. + managedAccountPasswordGroupCount = 6 + // managedAccountPasswordGroupLen is the number of characters per group. + managedAccountPasswordGroupLen = 4 + // managedAccountPasswordSeparator joins the groups. Grouping matters because this password is read + // off a screen and typed at a login prompt, not pasted. + managedAccountPasswordSeparator = "-" +) + +// Character classes for the managed account password. These deliberately omit characters that are +// easily confused when transcribed by hand: 0/O/o and 1/I/l. +const ( + managedAccountDigits = "23456789" + managedAccountUppercase = "ABCDEFGHJKLMNPQRSTUVWXYZ" + managedAccountLowercase = "abcdefghijkmnpqrstuvwxyz" +) + +// GenerateManagedLocalAccountPassword returns a cryptographically random password for the managed local admin account, +// formatted in hyphen-separated groups so it can be read aloud and typed at a login prompt without error. +// +// includeLowercase controls whether lowercase letters appear. +// +// The password is guaranteed to contain at least one character from each enabled class, so the category count never +// depends on chance. That guarantee is applied by discarding and redrawing a password that misses a class, rather than +// by seeding one character per class and shuffling: seeding skews the result towards balanced class counts, while +// redrawing stays exactly uniform over the passwords that satisfy the guarantee. Roughly one draw in 40 is discarded. +func GenerateManagedLocalAccountPassword(includeLowercase bool) string { + classes := []string{managedAccountDigits, managedAccountUppercase} + if includeLowercase { + classes = append(classes, managedAccountLowercase) + } + all := strings.Join(classes, "") + total := managedAccountPasswordGroupCount * managedAccountPasswordGroupLen + + chars := make([]byte, total) + for { + for i := range chars { + chars[i] = all[randomIndex(len(all))] + } + if containsEachClass(chars, classes) { + break + } + } + + groups := make([]string, 0, managedAccountPasswordGroupCount) + for i := 0; i < total; i += managedAccountPasswordGroupLen { + groups = append(groups, string(chars[i:i+managedAccountPasswordGroupLen])) + } + return strings.Join(groups, managedAccountPasswordSeparator) +} + +func containsEachClass(chars []byte, classes []string) bool { + for _, class := range classes { + if !strings.ContainsAny(string(chars), class) { + return false + } + } + return true +} + +// randomIndex returns a uniformly random value in [0, n), for any n that fits in an int32. +// +// It rejects draws in the final, short bucket rather than taking a plain modulo, which would bias +// the result towards early characters for any alphabet size that does not divide the draw range. +func randomIndex(n int) int { + const span = int64(1) << 32 + // limit rounds span down to the largest exact multiple of n, discarding that remainder. Note it is + // the size of the draw range, not of the alphabet: for n=56 it is 4294967264, only 32 short of span. + limit := span - span%int64(n) + var b [4]byte + for { + // crypto/rand.Read never returns an error; it crashes the program if the system entropy source + // fails, so there is no failure mode for a caller to handle. + _, _ = rand.Read(b[:]) + // Read the four bytes as one number uniform over [0, span). The redraw (v < limit) is unlikely since span and + // limit are very close together. + if v := int64(binary.BigEndian.Uint32(b[:])); v < limit { + return int(v % int64(n)) + } + } +} diff --git a/server/fleet/managed_local_account_test.go b/server/fleet/managed_local_account_test.go new file mode 100644 index 0000000000..885ea34e02 --- /dev/null +++ b/server/fleet/managed_local_account_test.go @@ -0,0 +1,55 @@ +package fleet + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateManagedLocalAccountPassword(t *testing.T) { + t.Parallel() + macOSAlphabet := managedAccountDigits + managedAccountUppercase + windowsAlphabet := macOSAlphabet + managedAccountLowercase + + const iterations = 50 + + for _, tt := range []struct { + name string + includeLowercase bool + alphabet string + }{ + {"macOS, single case", false, macOSAlphabet}, + {"Windows, with lowercase", true, windowsAlphabet}, + } { + t.Run(tt.name, func(t *testing.T) { + seen := make(map[string]struct{}, iterations) + + for range iterations { + password := GenerateManagedLocalAccountPassword(tt.includeLowercase) + seen[password] = struct{}{} + + groups := strings.Split(password, managedAccountPasswordSeparator) + require.Len(t, groups, managedAccountPasswordGroupCount, "password %q", password) + for _, group := range groups { + require.Len(t, group, managedAccountPasswordGroupLen, "password %q", password) + // Membership in this variant's alphabet is what holds the lowercase switch honest, and + // what catches an index that runs past the end of the alphabet. + for i := range len(group) { + require.Contains(t, tt.alphabet, string(group[i]), "character outside the alphabet in %q", password) + } + } + + require.True(t, strings.ContainsAny(password, managedAccountDigits), "no digit in %q", password) + require.True(t, strings.ContainsAny(password, managedAccountUppercase), "no uppercase in %q", password) + if tt.includeLowercase { + require.True(t, strings.ContainsAny(password, managedAccountLowercase), "no lowercase in %q", password) + } + } + + // Repeats do not happen by chance; they mean the generator has stopped being random. + assert.Len(t, seen, iterations, "generated a duplicate password") + }) + } +} diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index b3f0863323..76133ddbe1 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -2298,7 +2298,7 @@ func EnqueueManagedLocalAccountRotation( commander ManagedLocalAccountRotationCommander, hostUUID, accountUUID string, ) (cmdUUID string, err error, rollbackErr error) { - newPassword := GenerateManagedAccountPassword() + newPassword := fleet.GenerateManagedLocalAccountPassword(false) hashPlist, hashErr := GenerateSaltedSHA512PBKDF2Hash(newPassword) if hashErr != nil { return "", hashErr, nil diff --git a/server/mdm/apple/util.go b/server/mdm/apple/util.go index e58d0ce3b0..57cf889bae 100644 --- a/server/mdm/apple/util.go +++ b/server/mdm/apple/util.go @@ -190,10 +190,6 @@ func IsLessThanVersion(current string, target string) (bool, error) { } const ( - // ManagedAccountPasswordGroupCount is the number of character groups in a managed account password. - ManagedAccountPasswordGroupCount = 6 - // ManagedAccountPasswordGroupLen is the number of characters per group. - ManagedAccountPasswordGroupLen = 4 // pbkdf2Iterations is the number of PBKDF2 iterations for the managed account password hash. pbkdf2Iterations = 40000 // pbkdf2KeyLen is the derived key length in bytes (128 bytes as required by Apple). @@ -202,26 +198,6 @@ const ( pbkdf2SaltLen = 32 ) -// GenerateManagedAccountPassword generates a cryptographically random password -// in the same format as recovery lock passwords (e.g., "5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT"). -func GenerateManagedAccountPassword() string { - groups := make([]string, ManagedAccountPasswordGroupCount) - charsetLen := len(RecoveryLockPasswordCharset) - - for i := range ManagedAccountPasswordGroupCount { - randBytes := make([]byte, ManagedAccountPasswordGroupLen) - _, _ = rand.Read(randBytes) // rand.Read never returns an error; it panics on failure - - group := make([]byte, ManagedAccountPasswordGroupLen) - for j := range ManagedAccountPasswordGroupLen { - group[j] = RecoveryLockPasswordCharset[int(randBytes[j])%charsetLen] - } - groups[i] = string(group) - } - - return strings.Join(groups, "-") -} - // saltedSHA512PBKDF2 is the plist structure expected by Apple's AutoSetupAdminAccountItem.passwordHash. type saltedSHA512PBKDF2 struct { PBKDF2 pbkdf2Dict `plist:"SALTED-SHA512-PBKDF2"` diff --git a/server/mdm/apple/util_test.go b/server/mdm/apple/util_test.go index 483b003ffb..be625c1f44 100644 --- a/server/mdm/apple/util_test.go +++ b/server/mdm/apple/util_test.go @@ -1,7 +1,6 @@ package apple_mdm import ( - "strings" "testing" "github.com/fleetdm/fleet/v4/server/fleet" @@ -255,24 +254,6 @@ func TestIsRecoveryLockPasswordMismatchError(t *testing.T) { } } -func TestGenerateManagedAccountPassword(t *testing.T) { - pw := GenerateManagedAccountPassword() - - // Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 groups of 4 chars separated by dashes) - groups := strings.Split(pw, "-") - require.Len(t, groups, ManagedAccountPasswordGroupCount) - for _, g := range groups { - require.Len(t, g, ManagedAccountPasswordGroupLen) - for _, c := range g { - assert.Contains(t, RecoveryLockPasswordCharset, string(c)) - } - } - - // Two calls should produce different passwords (with overwhelming probability). - pw2 := GenerateManagedAccountPassword() - require.NotEqual(t, pw, pw2) -} - func TestGenerateSaltedSHA512PBKDF2Hash(t *testing.T) { data, err := GenerateSaltedSHA512PBKDF2Hash("test-password") require.NoError(t, err) diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 3e56ee9159..492cb8cea0 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -268,7 +268,7 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) var password string cmdUUID := uuid.New().String() if managedAdminAccountEnabled { - password = apple_mdm.GenerateManagedAccountPassword() + password = fleet.GenerateManagedLocalAccountPassword(false) passwordHash, err := apple_mdm.GenerateSaltedSHA512PBKDF2Hash(password) if err != nil { return err