4
\$\begingroup\$

The idea with this script is to provide a way to work with configuration (xml) files for PowerShell scripts. These files may contain credential information which needs to be secured; those would be held under a special element name, Credential. Someone setting up the configuration file could set it up with the password in plaintext. On the script's first run it would read this plaintext password and encrypt it, updating the config to flag it as encrypted. This saves the effort of manually working out the encrypted value each time the value's changed; rather set it and then run the script to have it automatically protect itself.

Get-Config reads the configuration file. It essentially just imports the XML, only finding any credential elements and converting them to PSCredentials. To achieve that, it also needs to convert the config data from being stored in XML format (i.e. which can't hold a PSCredential) to a Hashtable (which can).

Protect-Config is used to update the configuration file, finding any unencrypted passwords and ensuring they get encrypted (saving the config file again once done). An additional -PassThru parameter can be supplied to have this return the configuration (i.e. to save having to call Get-Config in addition to Protect-Config at the start of every script).

function Protect-Config {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({Test-Path -Path $_ -PathType 'Leaf'})]
        [string]$Path
        ,
        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )
    Process {
        [bool]$amended = $false
        [string]$safePath = Resolve-Path -Path $Path | Select-Object -ExpandProperty 'ProviderPath' #ensure the path is valid for .net as well as PS
        write-verbose $safePath
        $config = [xml](Get-Content -Path $safePath -Raw)
        $config.SelectNodes("//*/Credential/Password[not(./@IsEncrypted = 'true') and (./text())]") | %{
            Write-Verbose "Unencrypted data found; encrypting"
            $amended = $true
            $attribute = $config.CreateAttribute("IsEncrypted")
            $attribute.Value = 'true'
            $_.Attributes.Append($attribute)
            $_.InnerText = $_.InnerText | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString
        }    
        if ($amended) {$config.Save($safePath)}
        if ($PassThru.IsPresent) {Get-Config -Path $safePath}
    }
}

function Get-Config {
    [CmdletBinding(DefaultParameterSetName='ByPath')]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName='ByPath')]
        [ValidateScript({Test-Path -Path $_ -PathType 'Leaf'})]
        [string]$Path
        ,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName='ByElement')]
        [System.Xml.XmlElement]$Element
    )
    Process {
        $result = @{}
        if ($PSCmdlet.ParameterSetName -eq 'ByPath') {
            $config = [xml](Get-Content -Path $Path -Raw)
            $result = $config.DocumentElement | Get-Config
        } else {
            if ($Element.LocalName -eq 'Credential') {
                #Credentials have special handling rules to convert them to PSCredential objects
                [string]$u = $Element.SelectSingleNode('./Username/text()').Value
                [string]$p = $Element.SelectSingleNode('./Password/text()').Value
                if ($p) {
                    $result[$Element.LocalName] = [System.Management.Automation.PSCredential]::new($u, ($p | ConvertTo-SecureString))
                } else {
                    if ($u) {
                        $result[$Element.LocalName] = [System.Management.Automation.PSCredential]::new($u, ([System.Security.SecureString]::new()))
                    } else {
                        $result[$Element.LocalName] = [System.Management.Automation.PSCredential]::Empty
                    }
                }
            } else {
                $childElements = $Element.ChildNodes | Where-Object {$_.GetType().ToString() -eq 'System.Xml.XmlElement'}
                if ($childElements.Count -gt 0) {
                    $result[$Element.LocalName] = $childElements | Get-Config
                } else {
                    $result[$Element.LocalName] = $Element.SelectSingleNode('./text()').Value
                }
            }
        }
        $result
    }
}

A configuration file would look something like this:

<?xml version="1.0" encoding="utf-8"?>
<Config>
  <MyDatabase>
    <Credential>
      <Username>MyUsername</Username>
      <Password>MyUnencryptedPassword</Password>
    </Credential>
    <DbInstance>MyServer\MyInstance</DbInstance>
    <DbCatalog>MyCatalog</DbCatalog>
  </MyDatabase>
  <Path>\\server\share\subfolder</Path>
</Config>

Once the first run occurs, this would be updated to something like:

<?xml version="1.0" encoding="utf-8"?>
<Config>
  <MyDatabase>
    <Credential>
      <Username>MyUsername</Username>
      <Password IsEncrypted="true">01000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef9876543201000000abcdef98765432</Password>
    </Credential>
    <DbInstance>MyServer\MyInstance</DbInstance>
    <DbCatalog>MyCatalog</DbCatalog>
  </MyDatabase>
  <Path>\\server\share\subfolder</Path>
</Config>

Saving the passwords in plaintext is obviously a bad idea; the idea here would be for the admin to be aware that they need to immediately run the script after amending any credentials, to ensure this information is not visible on the file system for long.

This script is written for PowerShell v5. My scenario didn't require support for older versions, so this implementation's not taken those into account.

\$\endgroup\$
3
\$\begingroup\$

One of the main problems here I think is that the encrypted version of the password can only be decrypted by the account who ran the unencrypted configuration first, on the same computer.

Your current configuration format has no provision for this.

If that's ok, then why ever write the unencrypted credentials to disk? Let the unencrypted <Password /> element be blank and force a prompt on first run to fix it or don't allow a run without it being encrypted (force a Protect-Config call first).

If you need to allow for multiple encrypted versions of the same credential to account for multiple accounts reading the config (and I strongly recommend you account for this), then you either need multiple config files (where everything but the credential is redundant) or you need to change your configuration format.


Is there a reason you need to define your own configuration format? Is there a reason it needs to be XML? There are lots of alternatives.

Serialization

PowerShell has pretty good object serialization that, critically for this purpose, supports [PSCredential] natively.

Additionally there's a file format that I think you are already familiar with, .psd1 which is basically just supports a very stripped down, restricted language version of a PowerShell script, which is used to store a [HashTable] for configuration data.

You can use Import-PowerShellDataFile to get yourself a [HashTable] from this file format directly.

You can also define a [hashtable] type parameter on a function, and then use the [ArgumentToConfigurationDataTransformation()] attribute on it. This means you can directly pass a [hashtable], or you can pass a .psd1 filename to it and it will be automatically be read as a configuration file.

Going back to XML, if you store your data in a [hashtable] initially, you can use CliXML as your configuration format (direct serialization).

So given:

$myConfig = @{
    MyDatabase = @{
        Instance = 'MyInstance'
        Catalog = 'MyCatalog'
    }
    Path = 'C:\MyPath'
}

Maybe you just directly do:

$myConfig | Export-CliXml -LiteralPath C:\myConfig.xml

It's still human readable, though less modifyable directly. On the other hand it gives you the ability to store some complex objects.


Credential Storage

Let's take another step back and think about where you really want to be storing your credentials; custom config format or not.

I like the idea having credentials be stored in separate files. Your config can refer to them either by full path, or by a name (key?) that is used to build the path; I especially like if they are in a specific (sub?) directory.

This allows for little change to your current config format, but adds flexibility for multiple users and computers, and for the ability to completely exclude your credentials from source control which makes sense even when they are encrypted because they are only good on a single computer.

Additionally, using CliXML with [PSCredential] completely avoids dealing directly with secure strings and the various conversions.

Let's see what this would look like.

Directory Structure:

.\Project
|__ Config
|____ myConfig.xml
|__ Credentials
|____ cred_Account1_adminUser_thisComputer.xml
|____ cred_Account1_svcAcct_thisComputer.xml

(probably want to add Credentials to .gitignore or equivalent)

myConfig.xml

<?xml version="1.0" encoding="utf-8"?>
<Config>
  <MyDatabase>
    <Credential Name="Account1" />
    <DbInstance>MyServer\MyInstance</DbInstance>
    <DbCatalog>MyCatalog</DbCatalog>
  </MyDatabase>
  <Path>\\server\share\subfolder</Path>
</Config>

Code:

function Import-MyCredential {
[CmdletBinding()]
[OutputType([PSCredential])]
param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [ValidateNotNullOrEmpty()]
    [String]
    $AccountName ,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [String]
    $BasePath = $PSScriptRoot
)

    Process {
        $fileName = "cred_${AccountName}_${env:USERNAME}_${env:COMPUTERNAME}.xml"
        $file = $BasePath | Join-Path -ChildPath 'Credentials' | Join-Path -ChildPath $fileName

        Import-CliXml -LiteralPath $fileName
    }
}

This is your helper function to build the path from the key, based on running user and computer, and get you back a [PSCredential].

Now, I really don't like working directly with XML so I'm not going to rewrite your existing functions to deal with the new style of <Credential> element but you clearly have the skill to do so.

Storing Credentials

The next question then is how to store credentials. I strongly encourage you not to allow reading a plaintext password from disk even if just to rewrite it. Don't encourage that.

The process of writing the above style files is very simple:

$cred = Get-Credential
$cred | Export-CliXml -LiteralPath ("$PSScriptRoot\Credentials\cred_${AccountName}_${env:USERNAME}_${env:COMPUTERNAME}.xml")

(of course parameterize and clean that up)

The question then, is what is the workflow to get there?

  • Let the config refer to creds that don't exist; then prompt to write the cred file.
  • Allow (or require) writing the cred file first.

That's up to you and depends on how you want things to go.

To reiterate, I don't think allowing the writing of plaintext adds any benefit to outweigh its risks and drawbacks. If ok with an admin rewriting a password in a config file then they can be expected to have to run a command once to properly prompt for it and store it encrypted instead.

\$\endgroup\$
  • \$\begingroup\$ That's great feedback; thank-you. I didn't mention in my question, but yes, I wanted this script to only work for a given user on a given machine, as this is for a script which is run under the Windows Task Scheduler under a specific account, with the script and config files being secured such that only that account and the host server's admins may amend either (i.e. to prevent someone replacing the script with a "$Credential.GetNetworkCredential().Password | Set-Content '.\WhatsThePassword.txt'" hack). \$\endgroup\$ – JohnLBevan Dec 28 '17 at 20:38
  • \$\begingroup\$ I chose PSD1 initially, but as I wanted a way to update the file I switched to XML; since I couldn't find a serialize option, which I needed to allow the script to update the unencrypted password to an encrypted one. I made a comment on this here (comment still pending approval at time of writing, but the article's good for anyone reading this thread wondering about configuration files): ramblingcookiemonster.github.io/PowerShell-Configuration-Data \$\endgroup\$ – JohnLBevan Dec 28 '17 at 20:41
  • \$\begingroup\$ Agreed that where I went wrong was in writing the unencrypted password to disk in the first place; it felt like a simple idea for managing config files without needing additional scripts / tools / thought; but is the root cause of all of the additional complexity, and does add some security risk; which I guess shouldn't be sacrificed just for convenience. I wasn't familiar with ArgumentToConfigurationDataTransformation; thank-you for the tip. As always; awesome answer; so thank-you again. \$\endgroup\$ – JohnLBevan Dec 28 '17 at 20:43
  • 1
    \$\begingroup\$ @JohnLBevan I still think you should support multiple users, because it still only allows each user to unencrypt the one version of the credential they are supposed to have access to. I often do this for scheduled tasks that run under service accounts; you encrypt one version of a needed credential for the service account, and one for each admin who needs to run the script directly (this is intensely useful for debugging and development). Since the code infers file name based on current user, there's no need to change code based on testing (then change it back) or anything like that. \$\endgroup\$ – briantist Dec 28 '17 at 20:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.