New-Password.psm1

<##
 # Copyright 2021 David Hollings. All rights reserved.
 # Use of this source code is governed by a BSD-style
 # license that can be found in the LICENSE file.
#>


#Import Helper Scripts
. $PSScriptRoot/Private/vars.ps1
. $PSScriptRoot/Private/GetEntropy.ps1

Function New-Password {
    <#
    .SYNOPSIS
        Generate a random password either using random words (XKCD format) or a random character string.
    .DESCRIPTION
        Generate a random password using user-specified parameters. Supports either a random string or a word list.
        While characters are faster to generate they may be harder to remember so are best suited to service accounts
        or other systems where they are less likely to be entered manually. By default Character mode uses all alphanumeric
        characters and punctuation but can be customised.
        Word formatted passwords support a custom number of words and optional padding digits at the beginning and end. Word case is randomised to increase entropy. There will not be an option to customise word case.
        The word list cannot be specified at the command line but new wordlists can be imported using the Import-PSPwUtilsWordList cmdlet.
    .INPUTS
        None
    .OUTPUTS
        System.Management.Automation.PSCustomObject
    .EXAMPLE
        PS> New-Password -Length 30 -Characters Lower,Upper,Number

        Password BlindEntropy SeenEntropy
        -------- ------------ -----------
        X3EP14IvBHwPjTWqC5wJsYwDFSaDYL 179 179

        Generates a 30 digit random password using lower and upper case letters and numbers

    .EXAMPLE
        PS> New-Password -Word

        Password BlindEntropy SeenEntropy
        -------- ------------ -----------
        PURGING?spirits?maternal?DIVINITY 211 59

        Generates a new words type passphrase using default parameters

    .EXAMPLE
        PS> New-Password -Words 5 -PrefixDigits 1 -SuffixDigits 3 -PrefixSymbols 2 -SuffixSymbols 1 -SeparatorCharacters "_"

        Password BlindEntropy SeenEntropy
        -------- ------------ -----------
        &[_3_CRAFTER_conclude_DEFERRAL_SCRUFFY_EXPORTER_887_= 347 96

        Generate a 5 word password with 2 prefix symbols, 1 suffix symbol, 1 prefix digt and 3 suffix digits. Words are separated by underscores

    .EXAMPLE
        PS> New-Password -MinimumLength 6 -MaximumLength 9 -Count 4 -SeparatorCharacters "-_=+/\"

        Password BlindEntropy SeenEntropy
        -------- ------------ -----------
        coronary+AVERAGE+endurable+COLONIZE 224 56
        MOUSINESS\married\atrocious\smelting 230 56
        unsavory=THROWBACK=HARMONY=corporal 224 56
        ANOTHER/RIMLESS/UNCOATED/CAREFULLY 199 56

        Generate 4 passphrases using words containg between 6 and 9 letters. Separator character is randomly chosen from -, _, =, +, / and \

    .EXAMPLE
        PS> New-Password | Publish-Password

        Password : Mr?)wrP8+>JoC&YdZ&<e
        Days : 7
        Views : 5
        Deletable : True
        Link : https://pwpush.com/p/s0cbre91fkkjz6oo

        Generate a random password and create a sharable link using PwPush

    .LINK
        https://github.com/davidshomelab/PS-PwUtils
    #>

    [CmdletBinding(DefaultParameterSetName = "Character")]
    param(
        # Generate a Character password (Default).
        [Parameter(ParameterSetName = "Character")]
        [switch]
        $Character,

        #Number of characters to use in character password.
        [Parameter(ParameterSetName = "Character")]
        [ValidateRange(4, 255)]
        [int]
        $Length = 20,

        #Character set to use when generating character passwords.
        [Parameter(ParameterSetName = "Character")]
        [ValidateSet("Upper","Lower","Number","Symbol")]
        [string[]]
        $Characters = @("Upper","Lower","Number","Symbol"),

        # Generate a Word passphrase.
        [Parameter(ParameterSetName = "Word")]
        [switch]
        $Word,

        #Number of words to use in Word password.
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(1,14)]
        [int]
        $Words = 4,

        #Minimum length of words to use in Word password.
        [Alias("MinLength")]
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(4, 15)]
        [int]
        $MinimumLength = 4,

        #Maximum length of words to use in password.
        [Alias("MaxLength")]
        [Parameter(ParameterSetName = "Word")]
        [ValidateScript( {
                $_ -ge $MinimumLength -and $_ -le 15
            })]
        [int]
        $MaximumLength = 15,

        # Number of digits at beginning of password.
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(0,10)]
        [int]
        $PrefixDigits = 0,

        # Number of digits at end of password.
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(0,10)]
        [int]
        $SuffixDigits = 0,
        
        # String of characters to be used as separator character. One character in the string will be chosen at random. Duplicate values will be removed prior to processing.
        [Parameter(ParameterSetName = "Word")]
        [string]
        $SeparatorCharacters = $Symbols,

        # Number of random symbols at beginning of password.
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(0,10)]
        [int]
        $PrefixSymbols = 0,

        # Number of random symbols at end of password.
        [Parameter(ParameterSetName = "Word")]
        [ValidateRange(0,10)]
        [int]
        $SuffixSymbols = 0,

        # Character set for padding symbols. Provide the list of symbols as a string. Duplicate values will be removed prior to processing.
        [Parameter(ParameterSetName = "Word")]
        [string]
        $PaddingSymbols = $Symbols,

        # Generate the password as a secure string. True by default if the output is piped to another cmdlet, false if run as a standalone cmdlet.
        [Parameter()]
        [switch]
        $AsSecureString = $(if ($PSCmdlet.MyInvocation.PipelineLength -gt 1){$true}else {$false}),

        # How many passwords to generate
        [Parameter()]
        [int]
        $Count = 1
    )

    Write-Verbose "Generating $Count Passwords"
    Write-Verbose "Method: $($PSCmdlet.ParameterSetName)"
    if ($PSCmdlet.ParameterSetName -eq "Character"){
        Write-Verbose "Character Count: $Length"
        Write-Verbose "Permitted Character Categories: $Characters"

    }
    if ($PSCmdlet.ParameterSetName -eq "Word") {
        Write-Verbose "Words: $Words"
        Write-Verbose "Minumum Length: $MinimumLength"
        Write-Verbose "Maximum Length: $MaximumLength"
        Write-Verbose "Prefix Digits: $PrefixDigits"
        Write-Verbose "Suffix Digits: $SuffixDigits"
        Write-Verbose "Prefix Symbol Count: $PrefixSymbols"
        Write-Verbose "Suffix Symbol Count: $SuffixSymbols"
        Write-Verbose "Padding Symbol Characters: $PaddingSymbols"
        Write-Verbose "Separator Symbol Characters: $SeparatorCharacters"
        
    }

    if ($PSCmdlet.ParameterSetName -eq "Word") {
        # Import WordList. In the event that count is greater than 1 and we want a Words type password,
        # we import the dictionary outside the loop as it only needs to be done once
        $Wordlist = Import-Clixml $PSScriptRoot\words.xml
        $AvailableWords = New-Object -TypeName System.Collections.ArrayList
        for ($CurrentWordLength = $MinimumLength ; $CurrentWordLength -le $MaximumLength; $CurrentWordLength ++){
            Write-Verbose "Importing words of length $CurrentWordLength"
            Write-Debug "$($Wordlist[$CurrentWordLength])"
            $AvailableWords += $Wordlist[$CurrentWordLength] 
        }
        $WordlistLength = $AvailableWords.Length
        Write-Verbose "Found $WordlistLength available words"
        if ($WordlistLength -le 100){
            throw "Not enough available words, try setting less restrictive minimum and maximum lengths."
        }

        # Deduplicate separator characters
        $DeduplicatedSeparatorCharacters = ([char[]]$SeparatorCharacters | Select-Object -Unique) -join ""
        if ($SeparatorCharacters.Length -ne $DeduplicatedSeparatorCharacters.Length){
            Write-Verbose "Duplicate separator characters found. Deduplicating."
        }
        
        # Deduplicate padding symbols
        $DeduplicatedPaddingSymbols = ([char[]]$PaddingSymbols | Select-Object -Unique) -join ""
        if ($PaddingSymbols.Length -ne $DeduplicatedPaddingSymbols.Length){
            Write-Verbose "Duplicate separator characters found. Deduplicating."
        }
    }

    if ($PSCmdlet.ParameterSetName -eq "Character"){
        if ("Lower" -in $Characters){
            $AvailableCharacters += $LowerCase
        }
        if ("Upper" -in $Characters){
            $AvailableCharacters += $UpperCase
        }
        if ("Number" -in $Characters){
            $AvailableCharacters += $Numbers
        }
        if ("Symbol" -in $Characters){
            $AvailableCharacters += $Symbols
        }
    }

    (1..$Count) | ForEach-Object {
        if ($PSCmdlet.ParameterSetName -eq "Character") {
            $CharacterCount = $AvailableCharacters.Length
            $Password = ""
            (1..$Length) | ForEach-Object {
                $Random = Get-Random -Minimum 0 -Maximum $CharacterCount
                $SelectedCharacter = $AvailableCharacters[$Random]
                $Password += $SelectedCharacter
            }

            $EntropyParams = @{} # We will add parameters to this array later but for now just need to ensure it exists

        }


        # Word password
        else {

            # Initialise array to contain password components
            [string[]]$PasswordWords = @()

            # Generate prefix symbols
            if ($PrefixSymbols -gt 0){
                $PasswordWords += GenerateCharstring -Length $PrefixSymbols -Charset $DeduplicatedPaddingSymbols
            }

            # Generate prefix digits
            if ($PrefixDigits -gt 0){
                $PasswordWords += GenerateCharstring -Length $PrefixDigits -Charset $Numbers
            }

            # Generate $Words random words and add them to oputput array. Word case is decided randomly
            (1..$Words) | ForEach-Object {
                $Random = Get-Random -Minimum 0 -Maximum $AvailableWords.Length
                $SelectedWord = $AvailableWords[$Random]
                if ((Get-Random -Minimum 0 -Maximum 2) -eq 1) {
                    $SelectedWord = $SelectedWord.ToUpper()
                }
                $PasswordWords += $SelectedWord
            }

            # Generate suffix digits
            if ($SuffixDigits -gt 0){
                $PasswordWords += GenerateCharstring -Length $SuffixDigits -Charset $Numbers
            }

            # Generate suffix symbols
            if ($SuffixSymbols -gt 0){
                $PasswordWords += GenerateCharstring -Length $SuffixSymbols -Charset $DeduplicatedPaddingSymbols
            }

            # Choose separator character
            if ($DeduplicatedSeparatorCharacters.Length -gt 1) {
                $SeparatorIndex = Get-Random -Minimum 0 -Maximum ($DeduplicatedSeparatorCharacters.Length)
                $SeparatorCharacter = $DeduplicatedSeparatorCharacters[$SeparatorIndex]
            }
            else { $SeparatorCharacter = $DeduplicatedSeparatorCharacters }



            # Print final password
            $EntropyParams = @{
                Word = $true
                WordListLength = $WordlistLength
                WordCount = $Words
                PrefixSymbolCount = $PrefixSymbols
                SuffixSymbolCount = $SuffixSymbols
                SymbolSetSize = $DeduplicatedPaddingSymbols.Length
                PrefixDigitCount = $PrefixDigits
                SuffixDigitCount = $SuffixDigits
                SeparatorCharacterCount = $DeduplicatedSeparatorCharacters.length
            }

            $Password = $PasswordWords -join $SeparatorCharacter

        }

        # Convert password to secure string to pass to GetEntropy
        $SecurePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force

        #Add password to EntropyParams. We do this here because both string and word format passwords use this field
        $EntropyParams.Password = $SecurePassword

        if ($AsSecureString){
            $Password = $SecurePassword
        }

        $Entropy = GetEntropy @EntropyParams
        $Output = [PSCustomObject]@{
            Password = $Password
            BlindEntropy = $Entropy.BlindEntropy
            SeenEntropy = $Entropy.SeenEntropy
        }

        Write-Output $Output

    }
}