Generating and validating strong passwords for Windows/Active Directory

I’ve been thinking about the various issues surrounding password generation and validation for Windows & Active Directory over the last few days. Like any sysadmin who’s dealt with deploying AD I’ve reviewed the benefits of enfocing the “passwords must meet complexity requirements” security requirement (reduced attack surface area on domain-bound systems) as well as the costs (everyone finds it frustrating to come up with new passwords, particularly ones they can remember).

In the past I’ve used standalone random phrase/password generators for this sort of purpose, but even they can be imperfect if they don’t allow you to customise them. In my last role, for example, we had a number of complex systems that weren’t properly using shared authentication – rather, certain systems would synchronise their users database from other systems. Unhelpfully, some systems had more stringent restrictions on allowed characters in passwords than others, with the issue only becoming apparent when a user attempted to access the restricted system using what (as far as they were concerned) was a valid set of credentials.

This seems like a prime sort of task to automate away in PowerShell; it’s mundane, it’s repetitive, and the required parameters are well-documented (at least for a given value of “well”…). Looking around, I’ve found several different approaches to the problem. Indeed, this comment replying to one of those posts looks at first like it has solved the entire issue in a single call. At least, the random password generation part.

The problem with password generation is that we need to validate them before assigning them, at least for systems in mixed environments where additional constraints might need to be taken into account. Constraints can take many forms (for instance, I recall one system in which passwords were generated and then exported to a CSV file for import into another system, which meant that commas could not be part of the password as it would cause problems with the import process), but the Complexity Requirements checklist from Microsoft is a good starting point so let’s work from that. The functions I’ve written are based around both generating and validating passwords with the following requirements:

  1. Must be at least 8 chars long
  2. Must have mixture of cases
  3. Must contain at least one number
  4. Must contain at least one non-alphanumeric
  5. Must *not* contain the user’s forename, middle name or surname as a string

#5 on the list is interesting, because it has wider ramifications than the other conditions. For example, “Password_1” satisfies conditions 1-5 but would not be regarded as a particularly strong password (not least because the majority of it can be found using a dictionary attack), whereas “$%Kyle_434” would satisfy conditions 1-4 and not 5, while being percieved generally to be a stronger (or at least less weak) password. (In saying that, in the context of a brute-force attack both of those passwords are equivalently strong, according to the Haystack test). Close reading of the required parameters for strong passwords reveals that #5 is enforced as follows:
“If the account name is less than three characters long, this check is not performed because the rate at which passwords would be rejected is too high. When checking against the user’s full name, several characters are treated as delimiters that separate the name into individual tokens: commas, periods, dashes/hyphens, underscores, spaces, pound-signs and tabs. For each token that is three or more characters long, that token is searched for in the password; if it is present the password change is rejected. For example, the name “Erin M. Hagens” would be split into three tokens: “Erin,” “M,” and “Hagens.” Because the second token is only one character long, it would be ignored. Therefore, this user could not have a password that included either “erin” or “hagens” as a substring anywhere in the password. All of these checks are case insensitive.”

Having established this, we can break the requirements down into three distinct groupings:

  1. Length
  2. Complexity
  3. Contextual information

In the context of generating a password that satisfies these conditions, only 1 & 2 are relevant – we need to ensure the password is long enough, and we need to ensure that the pool of characters from which it is randomly generated is sufficiently mixed. 1 is easy, 2 is a bit complicated.

In the context of validating a password, 1 and 3 are pretty easy (a few suitably-structured if-tests should suffice) but 2 is a bit more complex as we need to break the password string down to individual components and check each one. We can use regular expressions to facilitate some of this, but in the context of non-alphanumeric characters a few characters have to be checked manually.

I think that establishes enough context for understanding why I have taken the approach I settled upon – the script below is written such that it can be run as a menu-driven utility (because I like menu-driven utilities!) but that’s primarily in order to serve as a proof-of-concept. I think it’s more likely to be useful as a set of custom cmdlets that can be integrated into other processes (eg new AD account setup, AD password resets etc) and offering the ability to customise the allowed character sets as per an organisation’s requirements. For example, an organisation might wish to limit the alphanumeric characters to remove all characters of a similar appearance to avoid confusion for its users, or may rely on an application which has a custom set of forbidden characters.

# Import the AD Module, for reading and writing data to the AD
Import-Module ActiveDirectory

function New-Password{
    # Initialise password variable
    [string]$global:password=$null

    # Invoke each add-character function once to ensure a minimum level of complexity
    New-Lower($global:password)
    New-Upper($global:password)
    New-Int($global:password)
    New-Special($global:password)

    # Continue adding characters using a randomised selection of add-character functions. The number of required characters can be increased if higher password entropy is required.
    do {
        if ($global:password.length -ge $reqlength) {
           $longenough=$true
        } else {
            $select=Get-Random -Minimum 1 -Maximum 4
            switch ($select) {
                "1" {
                    New-Lower($global:password)
                }
                "2" {
                    New-Upper($global:password)
                }
                "3" {
                    New-Int($global:password)
                }
                "4" {
                    New-Special($global:password)
                }
            }
        }

    } while (!$longenough)
    
    Write-Host "Password genertion complete, password is "$password

}

# Define a set of add-character functions each of which has a separate defined pool of characters pulled from the strong password complexity requirements 

function New-Lower {
    $lowerpool=@("a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z")
    $seed=Get-Random -minimum 0 -maximum $lowerpool.count
    $global:password+=$lowerpool[$seed]
}

function New-Upper {
    $upperpool=@("A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z")
    $seed=Get-Random -minimum 0 -maximum $upperpool.count
    $global:password+=$upperpool[$seed]
}

function New-Int {
    $intpool=@("0","1","2","3","4","5","6","7","8","9")
    $seed=Get-Random -minimum 0 -maximum $intpool.count
    $global:password+=$intpool[$seed]
}

function New-Special {
    # The ` character is used to escape characters for matching to avoid syntax issues when using the match command.
    $specialpool=@("(",")","``","~","!","@","#","$","%","^","&","*","-","+","=","|","\","{","}","[","]",":",";","`"","<",">",",",".","?","/")
    $seed=Get-Random -minimum 0 -maximum $specialpool.count
    $global:password+=$specialpool[$seed]
}

# Define the main function for checking password complexity.
function Check-Complexity {
    # Start a while-loop to iteratively check the password and call the add-character functions if required until complexity requirements are met.
    $passvalid=$false
    do {
        $passtest=$global:password.toCharArray()
	# Use a for loop and check each character using a switch. We need to create four booleans with the checks setting them as true once passed: upper, lower, int and special.

        $int = $false
	$lower = $false
	$upper = $false
	$special = $false
	
	for ($i=0; $i -lt $passtest.count;$i++) {
            try {
    	        $char = (Invoke-Expression '$passtest[$i]').GetType().Name
            } catch {
                Write-Host "Error encountered, message is "$_.Exception.Message
                Start-Sleep 3
            }
	    # Use regular expressions to identify the character type
	    # Lower case
	    if ($passtest[$i] -cmatch '[a-z]') {
	        # Character is a lower-case letter
                $lower=$true
	    }
	    # Upper case
	    if ($passtest[$i] -cmatch '[A-Z]') {
	        # Character is an upper-case letter
                $upper=$true
            }
	    if ($passtest[$i] -cmatch '[0-9]') {
	        # Character is an integer
                $int=$true
	    }
	    # Non-alphanumerics - these require multiple tests due to certain characters being reserved
	    if ($passtest[$i] -match '[][()`~!@#$%^&*-+=|\{}:;"?/]') {
	        # Character is a known non-alphanumeric character
                $special=$true
	    } else {
		# Copy character to a string so that we can use the Contains() method, as this handles special characters better than the Equals() method.
		[string]$testchar=$passtest[$i]
		if (($testchar.Contains("<")) -or ($testchar.Contains(">")) -or ($testchar.Contains(",")) -or ($testchar.Contains(".")) -or ($testchar.Contains("'")) -or ($testchar.Contains("-")) -or ($testchar.Contains("\"))) {
		    # Character is a known special non-alphanumeric character
                    $special=$true
		}
            }
	}
				
	# Use a concatenated if test to check that all the complexity requirements are met, and call a function to add additional characters if not based on which criteria haven't been met yet.
		
	if (($int) -and ($upper) -and ($lower) -and ($special) -and ($global:password.Length -ge 8)) {
	    # Password meets complexity criteria, can exit while loop
            Write-Host "Password meets all complexity requirements."
            Write-Host "Password value is" $global:password
	    $passvalid = $true
	} else {
            if ($global:password.Length -lt $reqlength) {
                Write-Host "Password too short, current length is"$global:password.Length" and required length is "$reqlength". Generating additional characters..."
                $select=Get-Random -Minimum 1 -Maximum 4
                switch ($select) {
                    "1" {
                        New-Lower($global:password)
                    }
                    "2" {
                        New-Upper($global:password)
                    }
                    "3" {
                        New-Int($global:password)
                    }
                    "4" {
                        New-Special($global:password)
                    }
                }
            }
	    if (!$lower) {
	        # Attempt to add a lower-case character
                Write-Host "No lower case characters found, generating additional characters..."
                New-Lower($global:password)
	    }
	    if (!$upper) {
	        # Attempt to add an upper-case character
                Write-Host "No upper case characters found, generating additional characters..."
		New-Upper($global:password)
	    }
	    if (!$int) {
	        # Attempt to add an integer
                Write-Host "No integer characters found, generating additional characters..."
		New-Int($global:password)
	    }
	    if (!$special) {
	        # Attempt to add a special character
                Write-Host "No special characters found, generating additional characters..."
		New-Special($global:password)
	    }
	}
    } while (!$passvalid)
}

# Define a function for checkig the password against each token from the user's name in AD.
function Check-Names {
    if (!$user) {
        Read-Host("Please enter the username/SAMAccountName for the user")
    }
    try {
        # Attempt to get user account information from AD
        $ADuser= Get-ADUser -Identity $user -Properties *
        # Create strings for each set of names, check whether they are long enough to be checked and if so check the password against them.
        try {
            [string]$GivenName = $ADUser.GivenName
            if ($GivenName.length -gt 3) {
                if (!($password.Contains($GivenName))) {
                    $givenvalid=$true
                } else {
                    $givenvalid=$false
                }
            } else {
                # Given Name is too short to require checking, no further test needed.
                $givenvalid=$true
            }
        } catch {
            Write-Host "Unable to check password against user's given name."
        }
        try {
            try {
                [string]$MiddleName=$ADuser.OtherName
            } catch {
                # Middle name is null, no check required.
            }
            if (($MiddleName) -and ($MiddleName.length -gt 3)) {
                if (!($password.Contains($MiddleName))) {
                    $middlevalid=$true
                } else {
                    $middlevalid=$false
                }
            } else {
                # Middle name is null or too short to require checking.
                $middlevalid=$true
            }
        } catch {
            Write-Host "Unable to check password against user's middle name."            
        }
        try {
            [string]$Surname=$ADUser.Surname
        } catch {
            # Surname is null, no check required
        }
            if (($Surname) -and ($Surname.length -gt 3)) {
                if (!($password.Contains($Surname))) {
                    $surnamevalid=$true
                } else {
                    $surnamevalid=$false
                }
            } else {
                # Middle name is null or too short to require checking.
                $surnamevalid=$true
            }
    } catch {
        Write-Host "Could not find user with username "$user "in Active Directory."
    }
    if (($givenvalid) -and ($middlevalid) -and ($surnamevalid)) {
        Write-Host "Password does not contain any forbidden strings."
    } else {
        if (!$givenvalid) {
            Write-Host "Password check against user's given name failed."
        }
        if (!$middlevalid) {
            Write-Host "Password check against user's middle name failed."
        }
        if (!$surnamevalid) {
            Write-Host "Password check against user's surname failed."
        }
    }
}

# Main body of the script for use as menu-driven utility. Everything below this line can be cut if only the functions are required.

Remove-Variable -Name * -Force -ErrorAction SilentlyContinue

# Define variable for required password length to ensure internal consistency.
[int]$reqlength = "8"

$quit=$false
do {
    Clear-Host
    Write-Host "1. Generate a new password."
    Write-Host "2. Enter a password and check its complexity."
    Write-Host "3. Quit."
    $menu=Read-Host("Please enter 1, 2 or 3 to choose from the available options")
    switch ($menu) {
        "1" {
            New-Password
            Start-Sleep 3
        }
        "2" {
            $global:password=Read-Host("Please enter the password you wish to check")
            Check-Complexity($global:password)
            Start-Sleep 3
        }
        "3" {
            $quit=$true
        }
        default {
        }
    }
} while (!$quit)

Advertisements

2 thoughts on “Generating and validating strong passwords for Windows/Active Directory

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s