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.

    .PARAMETER NotContaining
        Substrings the password must not contain, compared case-insensitively. A candidate
        holding any of them is discarded and another is drawn.

        This exists for one specific rule that is easy to forget. Windows password complexity
        does not only count character classes: it also refuses any password containing the
        account's sAMAccountName, or any token of its display name three characters or longer,
        where the display name is split on comma, full stop, hyphen, underscore, space, tab
        and hash. A seeded service account called "ZZ-TEST-Web Application Service" therefore
        rejects any password containing "Web" - or "TEST", which the seed prefix puts on every
        account - and Active Directory reports it as "the password does not meet the length,
        complexity, or history requirement of the domain", naming none of the three.

        Measured against the real generator, that refused roughly one seed run in three
        hundred, which is frequent enough to be seen and rare enough to look like something
        else. Callers creating directory accounts should pass the tokens of the name they are
        about to use.

    .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,

        [Parameter()]
        [string[]]$NotContaining = @()
    )

    $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]
        }

        # Bounded, because a caller could ask for something unsatisfiable - a forbidden
        # substring of one character, say - and an unbounded loop would hang a seed rather
        # than say so. At the real rates involved a second attempt is already vanishingly
        # unlikely, so a ceiling this high only ever catches a mistake in the request.
        $attemptLimit = 100

        for ($attempt = 1; $attempt -le $attemptLimit; $attempt++) {
            $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
            }

            $candidate = -join $characters

            $clash = $null
            foreach ($forbidden in $NotContaining) {
                if ([string]::IsNullOrEmpty($forbidden)) { continue }
                if ($candidate.IndexOf($forbidden, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
                    $clash = $forbidden
                    break
                }
            }

            if (-not $clash) { return $candidate }

            Write-Verbose "Regenerating: the candidate contained '$clash' (attempt $attempt)"
        }

        throw ("Could not generate a password avoiding all $($NotContaining.Count) forbidden " +
            "substrings in $attemptLimit attempts. Check that they are not so short or so many " +
            'that no password of this length could satisfy them.')
    }
    finally {
        $rng.Dispose()
    }
}