Public/New-HDTDeploymentPassword.ps1
|
function New-HDTDeploymentPassword { <# .SYNOPSIS Generates the per-deployment local Administrator password. .DESCRIPTION The Administrator password used during deployment is generated at run start (high entropy, stored only in the state document on the machine being built), not a fixed corporate password reused across the fleet. If it leaks it is worth one machine, mid-build." The value is returned and nothing else. It reaches exactly two places: the DefaultPassword LSA secret, and state.json's deploymentPassword - until Clear-HDTAutoLogon nulls it. It is never put in a record, a message, a report or the registry, and a test asserts that by reading this file's own text. THE ALPHABET, and why each excluded character is excluded: A-Z a-z 0-9 ! # $ * + - = ? @ _ 72 characters Everything else is deliberately absent. & < > " ' break unattend.xml (or force escaping that some consumers get wrong); % breaks %Var% expansion in a rules file or a command line; ^ | \ / and space break a command line. A password that cannot survive being handled is worse than a shorter one, and the entropy cost is small: 72 characters over 24 positions is about 148 bits. COMPLEXITY WITHOUT A RETRY LOOP. Windows complexity wants three of four character classes; this takes one character from each of the four by construction, fills the rest from the whole alphabet, and then shuffles. Generating and re-rolling until a password happened to satisfy the rule would work too, but it biases the distribution in a way that is hard to reason about and impossible to test. REJECTION SAMPLING, not modulo. Bytes come from the injected generator one at a time. A byte at or above the largest multiple of the target size is discarded and redrawn, so every character is equally likely. Folding a byte with a modulo would make the first 256 mod n characters of the alphabet more likely than the rest. RandomNumberGenerator's own integer helper is .NET Core only and does not exist under Windows PowerShell 5.1, which is the engine's floor, so the mapping is written out here. PowerShell's own random cmdlet is not used and must not be: it is not a CSPRNG. A test asserts its absence by reading this file, which is also why it is not named here. .PARAMETER Length How many characters. 16 to 127, defaulting to 24. .PARAMETER RandomNumberGenerator The source of randomness - anything exposing GetBytes([byte[]]). Defaults to [System.Security.Cryptography.RandomNumberGenerator]::Create(). Injectable so the byte-to-character mapping is testable: the same stream twice must give the same password. .OUTPUTS System.String .EXAMPLE $password = New-HDTDeploymentPassword A 24 character password, different on every call. .EXAMPLE $password = New-HDTDeploymentPassword -Length 32 Set-HDTAutoLogon -Registry $registry -Lsa $lsa -UserName 'Administrator' -Password $password -RemainingLeg 3 The only two things that are ever done with it. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Generates a value and returns it; it changes no state.')] [CmdletBinding()] [OutputType([string])] param( [Parameter()] [ValidateRange(16, 127)] [int] $Length = 24, [Parameter()] [object] $RandomNumberGenerator ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $generator = $RandomNumberGenerator $owned = $false if (-not $PSBoundParameters.ContainsKey('RandomNumberGenerator')) { $generator = [System.Security.Cryptography.RandomNumberGenerator]::Create() $owned = $true } try { # One uniformly distributed index in 0..($Size - 1), by rejection # sampling over single bytes. A scriptblock rather than a nested # function so the file holds exactly one command (DESIGN 15.1). $drawIndex = { param([int] $Size) $ceiling = [int] ([math]::Floor(256 / $Size)) * $Size $buffer = New-Object -TypeName 'System.Byte[]' -ArgumentList 1 do { $generator.GetBytes($buffer) } while ([int] $buffer[0] -ge $ceiling) return ([int] $buffer[0]) % $Size } $upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' $lower = 'abcdefghijklmnopqrstuvwxyz' $digit = '0123456789' $symbol = '!#$*+-=?@_' $every = $upper + $lower + $digit + $symbol $character = New-Object -TypeName System.Collections.ArrayList # One from each class first, so complexity is guaranteed rather than # likely, then the rest from the whole alphabet. foreach ($class in @($upper, $lower, $digit, $symbol)) { [void] $character.Add($class[(& $drawIndex $class.Length)]) } for ($index = $character.Count; $index -lt $Length; $index++) { [void] $character.Add($every[(& $drawIndex $every.Length)]) } # Fisher-Yates, so the four guaranteed characters are not always in the # first four positions - which would leak the alphabet's shape. for ($index = $character.Count - 1; $index -gt 0; $index--) { $swap = & $drawIndex ($index + 1) $held = $character[$index] $character[$index] = $character[$swap] $character[$swap] = $held } return (-join $character) } finally { if ($owned) { $generator.Dispose() } } } |