DEV Community

Cover image for Writing an Extension Vault for PowerShell SecretManagement Preview 4
Adam the Automator
Adam the Automator

Posted on

Writing an Extension Vault for PowerShell SecretManagement Preview 4

This article was written by Adam Listek. He is a frequent contributor to the Adam the Automator (ATA) blog. If you'd like to read more from this author, check out his ATA author page. Be sure to also check out more how-to posts on cloud computing, system administration, IT, and DevOps on adamtheautomator.com!

If you've ever hardcoded a password, an API key, or a private certificate in a script, stop! You need to secure that sensitive information! One way to do that is with the PowerShell SecretManagement module. Offering a convenient way for a user to store and retrieve secrets using PowerShell, the SecretManagement module also has the ability to interface with different back-end systems such as Azure KeyStore or KeePass too using an extension vault!

An extension vault is a term used within the SecretManagement module to refer to a nested PowerShell module used within the SecretManagement module. You'll also hear vaults referred to as extension modules also.

In this article, we create an extension vault that is backed by the open-source .NET database LiteDB. An extension module can use any back-end, but using LiteDB allows us to use a .NET native simple database that demonstrates the SecretManagement module capabilities and how to interact with different systems.

Note: Don't get confused with the "secret" in the name. Even though this module is meant to be used for "secret" management, as you'll see in this tutorial, it really could be used as a frontend or just about any data source, if you wish.

Prerequisites

This article is a tutorial. If you'd like to follow along, please be sure you have the following prerequisites in order.

  • PowerShell 7
  • The Powershell SecretManagement module - You can install this module by running Install-Module -Name 'Microsoft.PowerShell.SecretManagement' -AllowPrerelease. This tutorial will be using the most current version at the time of writing which is Preview 4.
  • The LiteDB database - Though you can create any backing store for your secret database, this article demonstrates creating an extension module to leverage LiteDB in the background. You can install LiteDB by running Install-Package -Name 'LiteDB' -MinimumVersion '5.0.9' -Source 'nuget.org' -Scope 'CurrentUser'

Understanding the PowerShell SecretManagement Module

The PowerShell SecretManagement module is a PowerShell module that provides a standardized, Microsoft-supported way of interacting with sensitive information. Think of this module as a better way of storing and retrieving sensitive information than using Get-Credential or perhaps storing secure strings in common text files.

This module has two main object types; secrets and vaults. A vault is a data store that stores secrets. To store and retrieve secrets, you must first register a vault to then add secrets to that vault. You can then unregister that vault if you no longer need to store secrets in that data source.

When you install the module, there are no actual vaults available. If you want to install the previously default extension vault, run Install-Module -Name 'Microsoft.PowerShell.SecretStore' -AllowPrerelease. Otherwise, keep reading as you'll learn how to create a custom one with LiteDb.

You'll find a few different cmdlets in the module to interact with the various objects.

  • Get-Secret - Retrieve a secret from a vault.
  • Get-SecretInfo - Retrieve information about one or more secrets in a vault.
  • Get-SecretVault - Retrieve information about a specific vault.
  • Register-SecretVault - Register a new secret vault.
  • Remove-Secret - Remove a secret from a vault.
  • Set-Secret - Create a new secret or update an existing secret in a vault.
  • Test-SecretVault - Verify that a vault is correctly configured.
  • Unregister-SecretVault - Unregister a previously registered vault.

To actually make any of the above cmdlets functional, you need to back them with a vault. To do that, the SecretManagement module requires you to build your own functions with the same name essentially overwriting the default cmdlets.

If your extension does not offer the ability to add or update a secret, you could implement the function but only throw an error message stating that it is not supported.

Next, we will explore how to structure a PowerShell SecretManagement extension vault and start to implement the commands.

Creating a PowerShell SecretManagement Extension Vault

Now that you have seen the basic cmdlet structure and objects you'll be working with, let's now learn how to create an example extension vault!

The root directory to create the first parent module directory is in the SecretManagement module's ExtensionModules folder. You can find this directory by running (Get-Module -Name Microsoft.PowerShell.SecretManagement).Path | Split-Path -Parent.

Create the Main Vault Folder and Manifest

  1. Open up PowerShell and create a variable holding the path to the ExtensionsModule parent directory which all underlying module folders and files will be created.
$vaultParentPath = (Get-Module -Name Microsoft.PowerShell.SecretManagement).Path | Split-Path -Parent. 

2. Create the parent module directory in the ExtensionModules directory. Name this directory SecretManagement.<Vault source>. In this case, the directory is called SecretManagement.LiteDB.

New-Item -Path "$vaultParentPath\SecretManagement.LiteDb" -ItemType Directory

3. Create a module manifest for the SecretManagement.LiteDb module outlining some common manifest attributes.

New-ModuleManifest -CompatiblePSEditions Core -Author '<you>' -NestedModules './SecretManagement.LiteDB.Extension/SecretManagement.LiteDB.Extension.psd1' -RequiredModules 'Microsoft.Powershell.SecretManagement' -PowerShellVersion 7.0 -Tags 'SecretManagement' -Path "$vaultParentPath\SecretManagement.LiteDb"

4. Once New-ModuleManifest creates the template manifest, open it up and ensure it mirrors the below manifest.

@{
  ModuleVersion        = '0.0.0.1'
  # Make this only compatible with PowerShell Core/7
  CompatiblePSEditions = @('Core')
  GUID                 = '<guid here>' ## Generate a GUID with New-Guid
  Author               = 'Adam Listek'
  # Pointed to the nested module definition file
  NestedModules   = @(
    './SecretManagement.LiteDB.Extension/SecretManagement.LiteDB.Extension.psd1'
  )
  # Make this module dependant on the SecretManagement module
  RequiredModules = @(
    "Microsoft.Powershell.SecretManagement"
  )
  # Set the minimum PowerShell version of 7.0 as Core is the only one supported by the primary module and this is the recommended PowerShell version
  PowershellVersion = '7.0'
  FunctionsToExport = @()
  CmdletsToExport   = @()
  VariablesToExport = @()
  AliasesToExport   = @()
  # Not necessary, but recommended to tag your module with SecretManagement to assist PowerShell Gallery users in finding your module
  PrivateData = @{
    PSData = @{
      Tags = @('SecretManagement')
    }
  }
}

Creating the Extension Vault Directory and Manifest

Once you've created the main extension folder and manifest, next create the extension vault module with its manifest. This module is a nested module inside of the main parent extension module directory.

  1. Create the extension vault's folder.
New-Item -Path "$vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension" -ItemType Directory

2. Create the module manifest. This manifest only requires two attributes; RootModule and FunctionsToExport.

You'll notice the FunctionsToExport parameter shows the same function names as the SecretManagement module itself. An extension vault's commands "overwrite" the default SecretManagement modules.

New-ModuleManifest -RootModule '.\SecretManagement.LiteDB.Extension.psm1' -FunctionsToExport @('Set-Secret','Get-Secret','Remove-Secret','Get-SecretInfo','Test-SecretVault') -Path "$vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension.psd1"

3. Once New-ModuleManifest creates the template manifest, open it up and ensure it mirrors the below manifest.

@{
  ModuleVersion     = '0.0.0.1'
  # The command definitions located in the same directory as this module
  RootModule        = '.\SecretManagement.LiteDB.Extension.psm1'
  # Only export the functions that the SecretManagement module expects
  FunctionsToExport = @('Set-Secret','Get-Secret','Remove-Secret','Get-SecretInfo','Test-SecretVault')
}

Now that our module is all set up, let's implement the functions themselves! As mentioned before, to allow the PowerShell SecretManagement module to interact with your extension vault, you must define functions with the exact same name as the ones shown below other than the LiteDb-specific function Open-Database.

Create the Vault Function "Scaffolding"

Since you'll be devouring a lot of code in this article, first build the PSM1 module "scaffolding". This will allow you to insert code into this framework as you go along.

  1. Create the extension vault PSM1 module by first creating a text file.
New-Item -Path "$vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1" -ItemType File

2. Copy and paste the following "scaffolding" code into the PSM1 file. Code-specific comments are inline.

You must use the standard function names defined above in the Understanding section to interact with the SecretManagement module. You can also add your own functions to the module as "helper" functions if you'd like as shown in the below PSM1 file (Open-Database).

# Necessary to correctly override and implement the existing SecretManagement functions
Using Namespace Microsoft.PowerShell.SecretManagement

# An internal function, specific to this LiteDB vault, that will be used to open the database for interaction
Function Open-Database {}

# The following five functions are the standard SecretManagement functions that we will implement
Function Get-Secret {}

Function Set-Secret {}

Function Remove-Secret {} 

Function Get-SecretInfo {}

Function Test-SecretVault {}

Build the Functions

The next step is to add functionality to the extension vault. This functionality means adding functions to the extension vault (module).

Though we are creating an interface to store secrets, in this tutorial, we are not implementing the database's encryption, and in a production database that would be recommended. Once the database has been encrypted, decryption is done by passing a password upon opening the database for reading or writing.

Creating the "Helper" Open-Database Function

Typically, you'll only be building the standard set of functions but for LiteDB, the tutorial has a "helper" function called Open-Database. This function is strictly an internal function utilized by the extension module to simplify opening the database and making it available to the exposed functions. Code comments in-line.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Open-Database {
  [CmdletBinding()]
  
  Param (
    # The path to the actual database file on disk
    [String]$Path
  )

  Begin {
    # Make the vault parameters from the Register-SecretVault function available
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters
  }

  Process {
        # Look for if the type has already been added. If it has not, attempt to find the DLL path and perform an Add-Type to make the LiteDB class available
    If ( -Not ([System.Management.Automation.PSTypeName]'LiteDB.LiteDatabase').Type ) {
      $standardAssemblyFullPath = (Get-ChildItem -Filter '*.dll' -Recurse (Split-Path (Get-Package -Name 'LiteDB').Source)).FullName | Where-Object {$_ -Like "*standard*"} | Select-Object -Last 1

      Add-Type -Path $standardAssemblyFullPath -ErrorAction 'SilentlyContinue'
    }

    Write-Verbose "Opening Database: $Path" -Verbose:$VaultParameters.Verbose
        
        # Despite the New method, this will open an existing database or create a new one if that database does not exist on disk
    $Database = [LiteDB.LiteDatabase]::New($Path)

    If ($Database) {
      Write-Verbose "Database Opened: $($Database | Out-String)" -Verbose:$VaultParameters.Verbose

            # Output the database variable for use by consuming functions
      $Database
    } Else {
      Throw "Failed to open Database"
    }
  }
}

You may notice that the above snippet is doing something odd, Write-Verbose "Verbose Message" -Verbose:$VaultParameters.Verbose. If you attempt to display a verbose message by passing in the -Verbose parameter to the function, these internal messages will not display. This makes troubleshooting difficult. To overcome that, pass an additional boolean parameter to the -Verbose parameter to force the verbose messaging to show.

Creating the Get-Secret Function

Now that you have defined the Open-Database utility function, create the Get-Secret function. This function will most likely be one of the most used functions. This function queries the data source (LiteDb in this case) and returns the "secret". Any specific code comments are in line.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Get-Secret {
  [CmdletBinding()]

  Param (
    [String]    $Name,
    [String]    $VaultName,
    [Hashtable] $AdditionalParameters
  )

  Begin {
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters

    $Database = Open-Database -Path $VaultParameters.Path
  }

  Process {
    Try {
      # Retrieve the collection (i.e. table) to store the secrets in
      $Collection = $Database.GetCollection($VaultParameters.Collection)
      # Make sure that an index exists on the Name field so that queries work properly
      $Collection.EnsureIndex('Name') | Out-Null

      Write-Verbose "Collection Opened: $($Collection | Out-String)" -Verbose:$VaultParameters.Verbose
    } Catch {
      Throw "Unable to open collection."
    }

    # Locate a single secret as defined by the name passed in
    $Secret = $Collection.FindOne("`$.Name = '$Name'")
    Write-Verbose "Result: $($Secret | Out-String)" -Verbose:$VaultParameters.Verbose

    If ($Secret) {
      # If a secret exists, return the raw value
      $Secret['Value'].RawValue
    } Else {
            # If no secret is found, return $null which is required by the SecretManagement module
      Return $Null
    }
  }

  End {
    Write-Verbose "Closing Database" -Verbose:$VaultParameters.Verbose
    $Database.Dispose()
  }
}

Creating the Set-Secret Function

Next, you'll need a function to store data in the extension vault. To do that, you'll need the Set-Secret function This function will either add or update an existing secret depending on what already exists in the database. Any specific code comments are in line.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Set-Secret {
  [CmdletBinding()]

  Param (
    [String]    $Name,
    [Object]    $Secret,
    [String]    $VaultName,
    [Hashtable] $AdditionalParameters
  )

  Begin {
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters

    $Database = Open-Database -Path $VaultParameters.Path
  }

  Process {
    Try {
      # Retrieve the collection (i.e. table) to store the secrets in
      $Collection = $Database.GetCollection($VaultParameters.Collection)
      # Make sure that an index exists on the Name field so that queries work properly
      $Collection.EnsureIndex('Name') | Out-Null

      Write-Verbose "Collection Opened: $($Collection | Out-String)" -Verbose:$VaultParameters.Verbose
    } Catch {
      Throw "Unable to open collection."
    }

    # Attempt to locate an existing secret in the database
    $Item = $Collection.FindOne("`$.Name = '$Name'")

    If ($Item) {
      # If a secret exists, set the value of that secret to the new secret value passed in
      $Item['value'] = $Secret

      Write-Verbose "Retrieved Existing Item to Update: $($Item | Out-String)" -Verbose:$VaultParameters.Verbose

      Try {
        # Attempt to update the existing secret with the new secret value
        $Collection.Update($Item)
      } Catch {
        Throw "Unable to update item."
      }

            # SecretManagement requires a boolean value to be returned
      Return $True
    } Else {
      # Since the data stored in the database is of the type BSON (Binary Structured Object Notation), we need a utility mapper that converts the PowerShell objects into BSON
      $BSONMapper = [LiteDB.BSONMapper]::New()

            # Construct the password object
      $PasswordObject = @{
        "Name"  = $Name
        "Value" = $Secret
      }

      Write-Verbose "Creating New Entry: $($PasswordObject | Out-String)" -Verbose:$VaultParameters.Verbose

      Try {
        # Attempt to insert the password object as a new entry
        $Collection.Insert($BSONMapper.ToDocument($PasswordObject))
      } Catch {
        Throw "Unable to insert item."
      }

      Return $True
    }
  }

  End {
    Write-Verbose "Closing Database" -Verbose:$VaultParameters.Verbose
    $Database.Dispose()
  }
}

Creating the Remove-Secret Function

You'll need a way to remove secrets from the extension vault. For that, you'll need the Remove-Secret function. This function identifies an existing entry and removes it via the _id attribute. Any specific code comments are in line.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Remove-Secret {
  [CmdletBinding()]

  Param (
    [String]    $Name,
    [String]    $VaultName,
    [Hashtable] $AdditionalParameters
  )

  Begin {
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters

    $Database = Open-Database -Path $VaultParameters.Path
  }

  Process {
    Try {
      # Retrieve the collection (i.e. table) to store the secrets in
      $Collection = $Database.GetCollection($VaultParameters.Collection)
      # Make sure that an index exists on the Name field so that queries work properly
      $Collection.EnsureIndex('Name') | Out-Null

      Write-Verbose "Collection Opened: $($Collection | Out-String)" -Verbose:$VaultParameters.Verbose
    } Catch {
      Throw "Unable to open collection."
    }

        # Attempt to locate an entry to remove
    $Item = $Collection.FindOne("`$.Name = '$Name'")

    If ($Item) {
      Write-Verbose "Removing Item: $($Item | Out-String)" -Verbose:$VaultParameters.Verbose

      Try {
                # Remove the entry using the _id attribute, internal to the LiteDB database
        $Collection.Delete($Item['_id'].RawValue)
      } Catch {
        Throw "Unable to delete item."
      }

            # SecretManagement requires a boolean value to be returned
      Return $True
    } Else {
      Return $False
    }
  }

  End {
    Write-Verbose "Closing Database" -Verbose:$VaultParameters.Verbose
    $Database.Dispose()
  }
}

Creating the Get-SecretInfo Function

Next up, create the Get-SecretInfo function. This function is similar to Get-Secret because it queries the data source but it doesn't return the vault. This function returns various information about the secret such as any metadata associated with the secret. Any specific code comments are in line.

There appears to be a bug in Preview 4 of the PowerShell SecretManagement module. Although the definition calls for just the Filter parameter, you cannot make this work without both the Name and Filter parameter added. The Name parameter is then all that's used. This will most likely change in the future.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Get-SecretInfo {
  [CmdletBinding()]

  Param(
    [String]    $Name,
    [String]    $Filter,
    [String]    $VaultName,
    [Hashtable] $AdditionalParameters
  )

  Begin {
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters

    $Database = Open-Database -Path $VaultParameters.Path
  }

  Process {
    Try {
      # Retrieve the collection (i.e. table) to store the secrets in
      $Collection = $Database.GetCollection($VaultParameters.Collection)
      # Make sure that an index exists on the Name field so that queries work properly
      $Collection.EnsureIndex('Name') | Out-Null

      Write-Verbose "Collection Opened: $($Collection | Out-String)" -Verbose:$VaultParameters.Verbose
    } Catch {
      Throw "Unable to open collection."
    }

    Write-Verbose "Testing Name: $Name" -Verbose:$VaultParameters.Verbose
    If ([String]::IsNullOrEmpty($Name)) {
            # If the Name parameter is empty, return all results from the database
      $Results = $Collection.FindAll()
    } Else {
            # If a name is provided return just a single result
      $Results = $Collection.FindOne("`$.Name = '$Name'")
    }

        # SecretManagement requires an array of specific types to be returned, Microsoft.PowerShell.SecretManagement.SecretInformation
        # The first entry is the name of the secret, not the value
        # The second entry is the type, in this case we are only storing strings
        # The third entry is the name of the vault itself
    $ResultsArray = $Results | ForEach-Object {
      [Microsoft.PowerShell.SecretManagement.SecretInformation]::New(
        $PSItem['Name'].RawValue,
        "String",
        $VaultName
      )
    }
    
        # Return an array, and if no results, return an empty array
    If ($ResultsArray) {
      Return $ResultsArray
    } Else {
      Return @()
    }
  }

  End {
    Write-Verbose "Closing Database" -Verbose:$VaultParameters.Verbose
    $Database.Dispose()
  }
}

Test-SecretVault

The final function is the Test-SecretVault which verifies that everything is set up correctly with your vault.

The Test-SecretVault function can be built to validate whatever you'd like. The Azure Keystore extension vault, for example, verifies your Azure subscription status.

For the LiteDb example you're building, the Test-SecretVault function below attempts to see if a database and collection already exist. If so, it returns $true; if not, it returns $false.

Copy and paste this code into your $vaultParentPath\SecretManagement.LiteDb\SecretManagement.LiteDB.Extension\SecretManagement.LiteDB.Extension.psm1 module.

Function Test-SecretVault {
  [CmdletBinding()]

  Param (
    [String]    $VaultName,
    [Hashtable] $AdditionalParameters
  )

  Begin {
    $VaultParameters = (Get-SecretVault -Name $VaultName).VaultParameters
  }

  Process {
        # Verify that there is an existing database and that it can be opened with the requested collection available
    If (Test-Path $VaultParameters.Path) {
      $Database   = Open-Database -Path $VaultParameters.Path
      $Collection = $Database.GetCollection($VaultParameters.Collection)

      If ($Database -And $Collection) {
        Return $True
      } Else {
        Return $False
      }
    }

    Return $False
  }

  End {
    $Database.Dispose()
  }
}

Using the PowerShell SecretManagement Extension Module

Now that you've built the extension vault, start using it! How?

  1. First, ensure you have the SecretManagement module loaded.
# Import the SecretManagement module to make the commands available
Import-Module -Name 'Microsoft.PowerShell.SecretManagement'

2. Register LiteDb as an extension vault with the Register-SecretVault cmdlet. To do so, you'll see all of the common parameters you'll need to pass to the cmdlet.

The below example is using PowerShell splatting but you can also pass the parameters on the same line if you wish.

$Params = @{
  # Arbitrary name of the vault which could be anything
  "Name"            = 'LiteDBStore'
  # Load the extension module from this directory, in this example, the current one we are in
  "ModuleName"      = './SecretManagement.LiteDB'
  # Define additional parameters to be available to our extension module
  "VaultParameters" = @{
    # Path to the LiteDB database
    'Path'       = "D:\secrets.db"
    # The name of the collection to store the secrets in, think of this as a table
    'Collection' = 'Secrets'
    # Whether to display the Write-Verbose messages in the code
    'Verbose'    = $False
  }
  # Should this vault be treated as the default vault
  "DefaultVault"    = $True
}

Register-SecretVault @Params

3. Run the Get-SecretVault command to ensure the SecretManagement module finds the new vault successfully.

Alt Text

4. Now that the vault has been registered, run a quick test to make sure all the functions work as expected. If all goes well, you should receive no errors with only nice, beautiful output.

# Verify that the vault is correctly configured
Test-SecretVault -Name 'LiteDBStore'
# Create a new secret entry
Set-Secret -Name 'FirstSecret' -Secret 'FirstSecretValue'
# Display all secret entries
Get-SecretInfo
# Get the password of the secret as plaintext, default is a securestring
Get-Secret -Name 'FirstSecret' -AsPlainText
# Remove the secret and verify that it is no longer available
Remove-Secret -Name 'FirstSecret' -Vault 'LiteDBStore'
Get-SecretInfo

Alt Text

To remove and reload a vault, as you make changes, unregister the vault by running Unregister-SecretVault -Name 'LiteDBStore'. If you're developing a new extension vault, also be sure to remove the module as you make changes with Remove-Module -Name 'SecretManagement.LiteDB'. Running Register-SecretVaultagain automatically imports your module.

Next Steps

Writing an extension vault for PowerShell SecretManagement preview 4 is not difficult once you grasped the structure and the intricacies of the SecretManagement module. This common framework will open up a world of possibilities for scripts to not worry about the backing structure of their credentials.

An extension module could point to a cloud-hosted keystore, or store secrets via an API in almost any location. The functions make abstracting this backend quick and easy!

Now find your favorite place to store secrets and see if you can build an extension vault for it using the SecretManagement module!

Top comments (0)