Core/New-TestPassword.ps1

function New-TestPassword {
    <#
    .SYNOPSIS
        Generates a random password that satisfies typical directory complexity rules

    .DESCRIPTION
        Seeded accounts need a password because no directory will create a user without
        one. Nothing is expected to sign in with it, so it is generated, used once, and never
        returned to the caller or written anywhere.

        Randomness comes from RandomNumberGenerator rather than Get-Random. Get-Random is
        seeded from the system clock and is not a cryptographic source; two accounts created
        in the same tick can receive the same password. That is an unimportant weakness for a
        lab account and a habit worth not forming, and the cryptographic source costs nothing
        here.

        The character selection avoids the modulo bias that comes from taking a random byte
        modulo the alphabet length, by rejecting the values in the final incomplete block.
        One guaranteed character is drawn from each of the four required classes and the
        result is shuffled, because a password whose first four characters are always
        upper, lower, digit, symbol is a pattern.

    .PARAMETER Length
        Password length. Most directories cap this well above the default here, which is
        comfortably above any complexity floor.

    .OUTPUTS
        System.String

    .EXAMPLE
        PS> $password = New-TestPassword

        DESCRIPTION: Generates a 32-character password meeting directory complexity requirements
        OUTPUT: A random string
        USE CASE: Called once per seeded user, and discarded immediately afterwards

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Changes no state. It computes a value in memory and returns it; the New verb describes constructing an object, not modifying anything.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [ValidateRange(16, 128)]
        [int]$Length = 32
    )

    $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
    $lower = 'abcdefghijkmnopqrstuvwxyz'
    $digit = '23456789'
    # Deliberately narrow. Directories accept a wider set, but these survive being pasted through
    # a shell, a CSV and a JSON body without one of them needing to be escaped.
    $symbol = '!#$%*+-=?@'
    $all = $upper + $lower + $digit + $symbol

    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try {
        $pick = {
            param([string]$Alphabet)

            # Reject sampling: bytes at or above the largest exact multiple of the alphabet
            # length are discarded, so every character is equally likely.
            $limit = 256 - (256 % $Alphabet.Length)
            $byte = [byte[]]::new(1)
            do { $rng.GetBytes($byte) } while ($byte[0] -ge $limit)
            return $Alphabet[$byte[0] % $Alphabet.Length]
        }

        $characters = [System.Collections.Generic.List[char]]::new()
        foreach ($class in $upper, $lower, $digit, $symbol) {
            $characters.Add((& $pick $class))
        }
        while ($characters.Count -lt $Length) {
            $characters.Add((& $pick $all))
        }

        # Fisher-Yates, so the guaranteed characters are not always in the first four
        # positions. The swap index comes from the same unbiased source.
        for ($i = $characters.Count - 1; $i -gt 0; $i--) {
            $limit = 256 - (256 % ($i + 1))
            $byte = [byte[]]::new(1)
            do { $rng.GetBytes($byte) } while ($byte[0] -ge $limit)
            $j = $byte[0] % ($i + 1)

            $swap = $characters[$i]
            $characters[$i] = $characters[$j]
            $characters[$j] = $swap
        }

        return (-join $characters)
    }
    finally {
        $rng.Dispose()
    }
}