Public/Set-HDTAutoLogon.ps1

function Set-HDTAutoLogon {
    <#
        .SYNOPSIS
            Arms autologon for the next n reboots.
 
        .DESCRIPTION
            Writes, in this order, through the injected services:
 
              HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
                AutoAdminLogon = '1' String
                DefaultUserName = -UserName String
                DefaultDomainName = -DomainName String (empty for a workgroup)
                AutoLogonCount = -RemainingLeg DWord
                DefaultPassword REMOVED, always
 
              LSA private data
                DefaultPassword = -Password
 
              HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
                HDTResume = -ResumeCommand String
 
            THE PASSWORD GOES TO LSA AND NOWHERE ELSE. Winlogon
            reads DefaultPassword from LSA private data as well as from the
            registry, so there is no plaintext in a hive that any local read can
            lift, in a registry backup, or in a captured image. SPIKES.md S7
            observed Windows itself doing exactly that, and S8 drove three real
            autologons with the registry DefaultPassword absent throughout - so
            this is the supported path and there is no registry fallback. The
            registry value is REMOVED rather than left alone, because an image or
            another tool may have put one there and the guarantee is about the
            machine, not about what this function wrote.
 
            The password never appears in a log record, a message, the state
            document or a report. Tests assert that by reading everything the
            filesystem was asked to write.
 
            -RemainingLeg IS LITERAL. SPIKES.md S8 observed Windows decrementing
            the count before handing the session over: armed at 3, the three
            autologged sessions read 2, 1 and 0, and the fourth boot did not
            autologon at all. So n buys exactly n more autologons, with no
            off-by-one. The caller computes it; 03-04 computes it as the number
            of Restart steps left plus one.
 
            ARMING TWICE LEAVES WHAT ARMING ONCE LEFT. Every write is a set, not
            an append, so a run that re-arms before each Restart step - which is
            what a multi-leg deployment requires, since RunOnce is consumed each leg -
            converges rather than accumulating.
 
            It updates -State but does NOT save it: the caller owns the write
            order, and 03-04 saves immediately afterwards.
 
        .PARAMETER Registry
            An IRegistryService.
 
        .PARAMETER Lsa
            An ILsaService.
 
        .PARAMETER UserName
            The account to autologon as. Normally the local Administrator.
 
        .PARAMETER Password
            That account's password, for this deployment only. Generate it with
            New-HDTDeploymentPassword.
 
        .PARAMETER RemainingLeg
            How many more autologons are needed. At least 1.
 
        .PARAMETER DomainName
            The account's domain. Empty for a workgroup machine, which is what a
            machine mid-deployment normally is.
 
        .PARAMETER ResumeCommand
            What RunOnce launches at logon. Defaults to
            powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\HDT\Start-HDTResume.ps1
 
        .PARAMETER State
            The run state document. Its autoLogon block is updated in place.
 
        .PARAMETER LogContext
            A log context. One reboot.arm record is written when supplied.
 
        .OUTPUTS
            None.
 
        .EXAMPLE
            Set-HDTAutoLogon -Registry $registry -Lsa $lsa -UserName 'Administrator' `
                -Password $password -RemainingLeg 3 -State $state -LogContext $log
 
            Three more legs, the password only in LSA.
    #>

    # SecureString and PSCredential are declined here on purpose, and it is worth
    # writing down why rather than letting a future reader "fix" it.
    #
    # This password is a per-deployment throwaway that DESIGN 4.5.2 requires to
    # exist in cleartext in two places anyway - the LSA secret Winlogon reads,
    # and state.json on the machine being built. LsaStorePrivateData takes an
    # LSA_UNICODE_STRING, so a SecureString would be unwrapped to plaintext
    # inside this process on every call regardless. It would move the plaintext
    # around, not remove it, while making the whole path harder to test and
    # adding a marshalling step that behaves differently on the two engines.
    #
    # What actually protects this value is the design, not the type: it is
    # random per deployment, it never reaches the registry, the log, a report or
    # a recorded operation, and Clear-HDTAutoLogon deletes it. Those are the
    # properties the tests assert.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '',
        Justification = 'DESIGN 4.5.2 stores this password as an LSA secret and in state.json in cleartext by design; a PSCredential would be unwrapped on every call and protects nothing here.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '',
        Justification = 'LsaStorePrivateData takes an LSA_UNICODE_STRING, so a SecureString would be converted back to plaintext in this process anyway. See the comment above the param block.')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Registry,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Lsa,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $UserName,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Password,

        [Parameter(Mandatory = $true)]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $RemainingLeg,

        [Parameter()]
        [AllowEmptyString()]
        [string] $DomainName = '',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ResumeCommand = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\HDT\Start-HDTResume.ps1',

        [Parameter()]
        [AllowNull()]
        [object] $State,

        [Parameter()]
        [AllowNull()]
        [object] $LogContext
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $winlogonPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
    $runOncePath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
    $secretName = 'DefaultPassword'
    $runOnceName = 'HDTResume'

    if (-not $PSCmdlet.ShouldProcess($winlogonPath, ("Arm autologon for {0} for {1} leg(s)" -f $UserName, $RemainingLeg))) {
        return
    }

    $Registry.SetValue($winlogonPath, 'AutoAdminLogon', '1', 'String')
    $Registry.SetValue($winlogonPath, 'DefaultUserName', $UserName, 'String')
    $Registry.SetValue($winlogonPath, 'DefaultDomainName', $DomainName, 'String')
    $Registry.SetValue($winlogonPath, 'AutoLogonCount', $RemainingLeg, 'DWord')

    # Unconditional and idempotent. Whatever left one there, it is not going to
    # be there when this returns.
    $Registry.RemoveValue($winlogonPath, $secretName)

    $Lsa.SetSecret($secretName, $Password)

    $Registry.SetValue($runOncePath, $runOnceName, $ResumeCommand, 'String')

    if ($null -ne $State) {
        $State.autoLogon.armed = $true
        $State.autoLogon.userName = $UserName
        $State.autoLogon.domainName = $DomainName
        $State.autoLogon.countSet = $RemainingLeg
        $State.autoLogon.secretName = $secretName
        $State.autoLogon.runOnceName = $runOnceName
    }

    if ($null -ne $LogContext) {
        # The user name and the count. Not the password, not the secret, not a
        # hash of either.
        Write-HDTLog -Context $LogContext -Event 'reboot.arm' `
            -Message ("Autologon armed for {0} for {1} more leg(s)" -f $UserName, $RemainingLeg) `
            -Data ([ordered] @{
                userName    = $UserName
                domainName  = $DomainName
                count       = $RemainingLeg
                secretName  = $secretName
                runOnceName = $runOnceName
            })
    }
}