Add Windows managed local admin account support to fleetd (#50088)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-08-03 11:15:20 -05:00
committed by GitHub
parent 05ec868641
commit 05bc7be89e
14 changed files with 834 additions and 50 deletions
+15
View File
@@ -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
@@ -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.
+5
View File
@@ -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))
}
+134
View File
@@ -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
}
@@ -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")
}
@@ -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")
})
}
@@ -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
}
-5
View File
@@ -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.
+94
View File
@@ -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))
}
}
}
@@ -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")
})
}
}
+1 -1
View File
@@ -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
-24
View File
@@ -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"`
-19
View File
@@ -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)
+1 -1
View File
@@ -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