diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index 6ff81f98..e552e43a 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -9,6 +9,7 @@ jobs: - uses: actions/checkout@v2 - name: Run Pester tests run: | + Install-Module Microsoft.PowerShell.SecretManagement Import-Module Pester -MinimumVersion 5.2 $cfg = [PesterConfiguration]@{Run=@{Exit=$true}} Invoke-Pester -Configuration $cfg @@ -22,6 +23,7 @@ jobs: - uses: actions/checkout@v2 - name: Run Pester tests run: | + Install-Module Microsoft.PowerShell.SecretManagement Import-Module Pester -MinimumVersion 5.2 $cfg = [PesterConfiguration]@{Run=@{Exit=$true}} Invoke-Pester -Configuration $cfg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e455db9a..7d7fe297 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,9 @@ Checkout the main branch of the repository and run `mkdocs serve` to self-host a ## Unit Tests -The tests in this project now use [Pester v5](https://pester.dev/docs/quick-start). My overall code coverage is still pretty low. So if you're handy at writing tests or have suggestions to improve the existing ones, suggestions and pull requests are most welcome. The recommended way to run the tests is in a separate PowerShell process due to some limitations in how Pester is able to test internal module stuff. For example: +The tests in this project now use [Pester v5](https://pester.dev/docs/quick-start). You will also need to have the `Microsoft.PowerShell.SecretManagement` module installed, but no vault extension modules or configuration is required. + +My overall code coverage is still pretty low. So if you're handy at writing tests or have suggestions to improve the existing ones, suggestions and pull requests are most welcome. The recommended way to run the tests is in a separate PowerShell process due to some limitations in how Pester is able to test internal module stuff. For example: ```powershell cd \path\to\Posh-ACME diff --git a/Posh-ACME/Private/Export-PluginArgs.ps1 b/Posh-ACME/Private/Export-PluginArgs.ps1 index 7eba68e7..fa35212d 100644 --- a/Posh-ACME/Private/Export-PluginArgs.ps1 +++ b/Posh-ACME/Private/Export-PluginArgs.ps1 @@ -93,11 +93,8 @@ function Export-PluginArgs { # but we have to pre-serialize things like SecureString and PSCredential # first because ConvertTo-Json can't deal with those natively. - # determine whether we're using a custom key - $encParam = @{} - if (-not [String]::IsNullOrEmpty($acct.sskey)) { - $encParam.Key = $acct.sskey | ConvertFrom-Base64Url -AsByteArray - } + # get the encryption parameter + $encParam = Get-EncryptionParam -Account $acct -EA Stop $pDataSafe = @{} foreach ($key in $pData.Keys) { diff --git a/Posh-ACME/Private/Get-EncryptionParam.ps1 b/Posh-ACME/Private/Get-EncryptionParam.ps1 new file mode 100644 index 00000000..80014598 --- /dev/null +++ b/Posh-ACME/Private/Get-EncryptionParam.ps1 @@ -0,0 +1,64 @@ +function Get-EncryptionParam { + [OutputType([hashtable])] + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0)] + [PSTypeName('PoshACME.PAAccount')]$Account + ) + + # return early if sskey is empty or not defined + if ([String]::IsNullOrEmpty($Account.sskey)) { + return @{} + } + + if ('VAULT' -ne $Account.sskey) { + # an sskey value of anything except 'VAULT' should mean the key string + # is directly attached to the account object + $keyString = $Account.sskey + } + else { + # retrieve the key from the SecretManagement Vault if possible + + # make sure we have the necessary SecretManagement commands available + if (-not (Get-Command 'Unlock-SecretVault' -EA Ignore) -or + -not (Get-Command 'Get-Secret' -EA Ignore) ) + { + Write-Error "Unable to retrieve encryption key. Commands associated with SecretManagement module not found. Make sure Microsoft.PowerShell.SecretManagement is installed and accessible." -Category 'NotInstalled' + return @{} + } + + # make sure we have a vault name + $vaultName = $env:POSHACME_VAULT_NAME + if ([string]::IsNullOrWhiteSpace($vaultName)) { + Write-Error "Unable to retrieve encryption key. SecretManagement Vault name not found. Make sure POSHACME_VAULT_NAME and related environment variables are defined." -Category 'ObjectNotFound' + return @{} + } + + # build the secret name + if ([String]::IsNullOrEmpty($env:POSHACME_VAULT_SECRET_TEMPLATE)) { + $secretName = 'poshacme-{0}-sskey' -f $Account.VaultGuid + } else { + Write-Debug "Using custom secret template: $($env:POSHACME_VAULT_SECRET_TEMPLATE)" + $secretName = $env:POSHACME_VAULT_SECRET_TEMPLATE -f $Account.VaultGuid + } + + # if a vault password is defined, explicitly unlock the vault + if (-not [string]::IsNullOrEmpty($env:POSHACME_VAULT_PASS)) { + $ssPass = ConvertTo-SecureString $env:POSHACME_VAULT_PASS -AsPlainText -Force + Unlock-SecretVault -Name $vaultName -Password $ssPass + } + + # Attempt to get the key + try { + Write-Debug "Attempting to retrieve secret '$secretName' from vault '$vaultName'" + $keyString = Get-Secret -Vault $vaultName -Name $secretName -AsPlainText -EA Stop + } catch { + $PSCmdlet.WriteError($_) + return @{} + } + } + + # return the hydrated key as a hashtable to splat + $keyBytes = $keyString | ConvertFrom-Base64Url -AsByteArray + return @{ Key = $keyBytes } +} diff --git a/Posh-ACME/Private/Set-AltPluginEncryption.ps1 b/Posh-ACME/Private/Set-AltPluginEncryption.ps1 new file mode 100644 index 00000000..5ae504ef --- /dev/null +++ b/Posh-ACME/Private/Set-AltPluginEncryption.ps1 @@ -0,0 +1,144 @@ +function Set-AltPluginEncryption { + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0,ValueFromPipeline,ValueFromPipelineByPropertyName)] + [Alias('Name')] + [string]$ID, + [Parameter(Mandatory)] + [switch]$Enable, + [switch]$Reset + ) + + Begin { + # make sure we have a server configured + if (-not ($server = Get-PAServer)) { + throw "No ACME server configured. Run Set-PAServer first." + } + + # save the current account to revert to if necessary + $revertToAccount = Get-PAAccount + } + + Process { + + # set the specified account as current + if (-not $revertToAccount -or $revertToAccount.id -ne $ID) { + Write-Debug "Temporarily switching to account '$ID'" + Set-PAAccount -ID $ID + } + + # return early if there's nothing to do + $oldSSKey = $script:Acct.sskey + if ($Enable -and -not $Reset -and -not [String]::IsNullOrWhiteSpace($oldSSKey)) { + Write-Debug "AltPluginEncryption is already enabled on account '$ID'." + return + } elseif (-not $Enable -and [String]::IsNullOrWhiteSpace($oldSSKey)) { + Write-Debug "AltPluginEncryption is already disabled on account '$ID'." + return + } + + # grab a copy of the orders and plugin args before we break + # the ability to decrypt them + $orderData = @(Get-PAOrder -List | ForEach-Object { + @{ + Order = $_ + PluginArgs = ($_ | Get-PAPluginArgs) + } + }) + Write-Debug "Order data found for $($orderData.Count) orders." + + if ($Enable) { + + # generate a new key in case we need it + $newSSKey = New-AesKey + + # check for vault config + if (-not [string]::IsNullOrWhiteSpace($env:POSHACME_VAULT_NAME)) { + try { + $vaultName = $env:POSHACME_VAULT_NAME + + # make sure we have the necessary SecretManagement commands available + if (-not (Get-Command 'Unlock-SecretVault' -EA Ignore) -or + -not (Get-Command 'Get-Secret' -EA Ignore) ) + { + throw "Commands associated with SecretManagement module not found. Make sure Microsoft.PowerShell.SecretManagement is installed and accessible." + } + + # if a vault password is defined, explicitly unlock the vault + if (-not [string]::IsNullOrEmpty($env:POSHACME_VAULT_PASS)) { + $ssPass = ConvertTo-SecureString $env:POSHACME_VAULT_PASS -AsPlainText -Force + Unlock-SecretVault -Name $vaultName -Password $ssPass + } + + # get or create the vault guid + $vaultGuid = $script:Acct.VaultGuid + if ([string]::IsNullOrWhiteSpace($vaultGuid)) { + $vaultGuid = (New-Guid).ToString().Replace('-','') + } + + # build the secret name + if ([String]::IsNullOrEmpty($env:POSHACME_VAULT_SECRET_TEMPLATE)) { + $secretName = 'poshacme-{0}-sskey' -f $vaultGuid + } else { + Write-Debug "Using custom secret template: $($env:POSHACME_VAULT_SECRET_TEMPLATE)" + $secretName = $env:POSHACME_VAULT_SECRET_TEMPLATE -f $vaultGuid + } + + # check for an existing key value + $oldSecret = Get-Secret -Vault $vaultName -Name $secretName -AsPlainText -EA Ignore + + if ($Reset -or -not $oldSecret) { + # attempt to write a new vault key + Write-Debug "Attempting to add new secret '$secretName' to vault '$vaultName'." + Set-Secret -Vault $vaultName -Name $secretName -Secret $newSSKey -EA Stop + Write-Verbose "Enabling AltPluginEncryption for account '$ID' with new vault key." + } else { + # use the existing vault key + Write-Verbose "Enabling AltPluginEncryption for account '$ID' with existing vault key." + $newSSKey = $oldSecret + } + + $script:Acct | Add-Member 'sskey' 'VAULT' -Force + $script:Acct | Add-Member 'VaultGuid' $vaultGuid -Force + } + catch { + Write-Warning "Unable to save encryption key to secret vault. $($_.Exception.Message)" + + # just save the key onto the account + Write-Debug "Saving account $ID with new sskey." + $script:Acct | Add-Member 'sskey' $newSSKey -Force + } + } else { + # just save the key onto the account + Write-Debug "Saving account $ID with new sskey." + $script:Acct | Add-Member 'sskey' $newSSKey -Force + } + + } else { + # remove the key + Write-Verbose "Disabling AltPluginEncryption for account '$ID'" + $script:Acct | Add-Member 'sskey' $null -Force + } + + $acctFile = Join-Path $server.Folder "$ID\acct.json" + $script:Acct | Select-Object -Property * -ExcludeProperty id,Folder | + ConvertTo-Json -Depth 5 | + Out-File $acctFile -Force -EA Stop + + # re-export all the plugin args + $orderData | ForEach-Object { + Write-Debug "Re-exporting plugin args for order '$($_.Order.Name)' with plugins $($_.Order.Plugin -join ',') and data $($_.PluginArgs | ConvertTo-Json -Depth 5)" + Export-PluginArgs @_ -IgnoreExisting + } + } + + End { + $curAcct = Get-PAAccount + if ($revertToAccount -and + (-not $curAcct -or ($curAcct.id -ne $revertToAccount.id) )) + { + Write-Debug "Reverting to previously active account '$($revertToAccount.id)'" + Set-PAAccount -ID $revertToAccount.id + } + } +} diff --git a/Posh-ACME/Private/Update-PluginEncryption.ps1 b/Posh-ACME/Private/Update-PluginEncryption.ps1 deleted file mode 100644 index ee1c9ff3..00000000 --- a/Posh-ACME/Private/Update-PluginEncryption.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -function Update-PluginEncryption { - [CmdletBinding()] - param( - [Parameter(Mandatory,Position=0,ValueFromPipeline,ValueFromPipelineByPropertyName)] - [Alias('Name')] - [string]$ID, - [string]$NewKey - ) - - Begin { - # make sure we have a server configured - if (-not ($server = Get-PAServer)) { - throw "No ACME server configured. Run Set-PAServer first." - } - } - - Process { - - # set the specified account as current and prepare to revert when we're done - $revertToAccount = Get-PAAccount - if ($revertToAccount -and $revertToAccount.id -ne $ID) { - Write-Debug "Temporarily switching to account '$ID'" - Set-PAAccount -ID $ID - } - - # grab a copy of the orders and plugin args before we break - # the ability to decrypt them - $orderData = @(Get-PAOrder -List | ForEach-Object { - @{ - Order = $_ - PluginArgs = ($_ | Get-PAPluginArgs) - } - }) - Write-Debug "Order data found for $($orderData.Count) orders." - - # update and save the account with the new key - if ($NewKey) { - Write-Debug "Saving account $ID json with new sskey." - $script:Acct | Add-Member 'sskey' $NewKey -Force - } else { - Write-Debug "Saving account $ID json with null sskey." - $script:Acct | Add-Member 'sskey' $null -Force - } - $acctFile = Join-Path $server.Folder "$ID\acct.json" - $script:Acct | Select-Object -Property * -ExcludeProperty id,Folder | - ConvertTo-Json -Depth 5 | - Out-File $acctFile -Force -EA Stop - - # re-export all the plugin args - $orderData | ForEach-Object { - Write-Debug "Re-exporting plugin args for order '$($_.Order.Name)' with plugins $($_.Order.Plugin -join ',') and data $($_.PluginArgs | ConvertTo-Json -Depth 5)" - Export-PluginArgs @_ -IgnoreExisting - } - - # revert the active account if necessary - if ($revertToAccount -and $revertToAccount.id -ne $ID) { - Write-Debug "Reverting to previously active account '$($revertToAccount.id)'" - Set-PAAccount -ID $revertToAccount.id - } - } -} diff --git a/Posh-ACME/Public/Get-PAPluginArgs.ps1 b/Posh-ACME/Public/Get-PAPluginArgs.ps1 index ef3c914d..8f63c28b 100644 --- a/Posh-ACME/Public/Get-PAPluginArgs.ps1 +++ b/Posh-ACME/Public/Get-PAPluginArgs.ps1 @@ -18,7 +18,7 @@ function Get-PAPluginArgs { } Process { - trap { $PSCmdlet.ThrowTerminatingError($PSItem) } + trap { $PSCmdlet.WriteError($_); return } # try to find an order using the specified parameters $order = Get-PAOrder @PSBoundParameters @@ -42,11 +42,8 @@ function Get-PAPluginArgs { # drop support for PS 5.1. $pDataSafe = Get-Content $pDataFile -Raw -Encoding utf8 -EA Ignore | ConvertFrom-Json - # determine whether we're using a custom key - $encParam = @{} - if (-not [String]::IsNullOrEmpty($acct.sskey)) { - $encParam.Key = $acct.sskey | ConvertFrom-Base64Url -AsByteArray - } + # get the encryption parameter + $encParam = Get-EncryptionParam -Account $acct -EA Stop # Convert it to a hashtable and do our custom deserialization on SecureString # and PSCredential objects. diff --git a/Posh-ACME/Public/New-PAAccount.ps1 b/Posh-ACME/Public/New-PAAccount.ps1 index 3776dd8e..5a1d03c5 100644 --- a/Posh-ACME/Public/New-PAAccount.ps1 +++ b/Posh-ACME/Public/New-PAAccount.ps1 @@ -199,11 +199,6 @@ function New-PAAccount { Folder = Join-Path $server.Folder $ID } - # add a new AES key if specified - if ($UseAltPluginEncryption) { - $acct.sskey = New-AesKey - } - # save it to memory and disk $acct.id | Out-File (Join-Path $server.Folder 'current-account.txt') -Force -EA Stop $script:Acct = $acct @@ -214,5 +209,11 @@ function New-PAAccount { ConvertTo-Json -Depth 5 | Out-File (Join-Path $acct.Folder 'acct.json') -Force -EA Stop + # add a new AES key if specified + if ($UseAltPluginEncryption) { + $acct | Set-AltPluginEncryption -Enable + $acct = Get-PAAccount + } + return $acct } diff --git a/Posh-ACME/Public/Set-PAAccount.ps1 b/Posh-ACME/Public/Set-PAAccount.ps1 index 4353c199..5550e25f 100644 --- a/Posh-ACME/Public/Set-PAAccount.ps1 +++ b/Posh-ACME/Public/Set-PAAccount.ps1 @@ -98,28 +98,15 @@ function Set-PAAccount { $saveAccount = $false - # deal with local changes + # deal with encryption changes if ('UseAltPluginEncryption' -in $PSBoundParameters.Keys -or $ResetAltPluginEncryption) { - # UseAltPluginEncryption takes precedence over ResetAltPluginEncryption - # So if Use:$false + Reset is specified, the key is removed rather than rotated - - if ([String]::IsNullOrEmpty($acct.sskey) -and $UseAltPluginEncryption) - { - Write-Verbose "Adding new sskey for account $($acct.ID)" - Update-PluginEncryption $acct.ID -NewKey (New-AesKey) - } - elseif (-not [String]::IsNullOrEmpty($acct.sskey) -and - 'UseAltPluginEncryption' -in $PSBoundParameters.Keys -and - -not $UseAltPluginEncryption) - { - Write-Verbose "Removing sskey for account $($acct.ID)" - Update-PluginEncryption $acct.ID -NewKey $null - } - elseif (-not [String]::IsNullOrEmpty($acct.sskey) -and $ResetAltPluginEncryption) { - Write-Verbose "Changing sskey for account $($acct.ID)" - Update-PluginEncryption $acct.ID -NewKey (New-AesKey) + $encSplat = @{ + Enable = $UseAltPluginEncryption.IsPresent + Reset = $ResetAltPluginEncryption.IsPresent } + if ($encSplat.Reset) { $encSplat.Enable = $true } + $acct | Set-AltPluginEncryption @encSplat } # deal with server side changes diff --git a/Tests/Get-EncryptionParam.Tests.ps1 b/Tests/Get-EncryptionParam.Tests.ps1 new file mode 100644 index 00000000..a86804fa --- /dev/null +++ b/Tests/Get-EncryptionParam.Tests.ps1 @@ -0,0 +1,194 @@ +Describe "Get-EncryptionParam" { + + BeforeAll { + $env:POSHACME_HOME = 'TestDrive:\' + Import-Module (Join-Path $PSScriptRoot '..\Posh-ACME\Posh-ACME.psd1') + } + + Context "No Alt Encryption" { + + BeforeAll { + # copy a fake config root to the test drive + Get-ChildItem "$PSScriptRoot\TestFiles\ConfigRoot\" | Copy-Item -Dest 'TestDrive:\' -Recurse + + $env:POSHACME_VAULE_NAME = $null + + InModuleScope Posh-ACME { Import-PAConfig } + } + + It "Returns Empty Hashtable" { + InModuleScope -ModuleName Posh-ACME { + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 0 + } + } + } + + Context "SSKey on Account" { + + BeforeAll { + # copy a fake config root to the test drive + Get-ChildItem "$PSScriptRoot\TestFiles\ConfigRoot\" | Copy-Item -Dest 'TestDrive:\' -Recurse + + $env:POSHACME_VAULE_NAME = $null + + InModuleScope Posh-ACME { Import-PAConfig } + } + + It "Returns Key Splat" { + InModuleScope -ModuleName Posh-ACME { + # add a fake key to the account + $acct = Get-PAAccount + $acct | Add-Member 'sskey' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -Force + + $encParam = Get-EncryptionParam -Account $acct + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 1 + 'Key' | Should -BeIn $encParam.Keys + ConvertTo-Base64Url $encParam.Key | Should -Be 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + } + } + } + + Context "SSKey in Vault" { + + BeforeAll { + # copy a fake config root to the test drive + Get-ChildItem "$PSScriptRoot\TestFiles\ConfigRoot\" | Copy-Item -Dest 'TestDrive:\' -Recurse + + $env:POSHACME_VAULT_NAME = 'fake-vault' + + # tweak the account to indicate a VAULT sskey + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'VAULT' -Force + $acct | Add-Member 'VaultGuid' 'fakeguid' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + InModuleScope Posh-ACME { Import-PAConfig } + } + + It "Handles Missing SecretManagement Module" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Command {} + Mock Write-Error {} + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 0 + + Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { + $Message -like '*SecretManagement module not found*' + } + } + } + + It "Handles Missing Vault Name" { + InModuleScope -ModuleName Posh-ACME { + + Mock Write-Error {} + $env:POSHACME_VAULT_NAME = $null + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 0 + + Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { + $Message -like '*Vault name not found*' + } + + $env:POSHACME_VAULT_NAME = 'fake-vault' + } + } + + It "Retrieves Vault Key" { + InModuleScope -ModuleName Posh-ACME { + + Mock Write-Error {} + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 1 + 'Key' | Should -BeIn $encParam.Keys + ConvertTo-Base64Url $encParam.Key | Should -Be 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + Should -Invoke Get-Secret -Times 1 -Exactly -ParameterFilter { + $Vault -eq 'fake-vault' -and $Name -eq 'poshacme-fakeguid-sskey' + } + + } + } + + It "Retrieves Vault Key - Custom Template" { + InModuleScope -ModuleName Posh-ACME { + + Mock Write-Error {} + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + $env:POSHACME_VAULT_SECRET_TEMPLATE = 'a-{0}-b' + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 1 + 'Key' | Should -BeIn $encParam.Keys + ConvertTo-Base64Url $encParam.Key | Should -Be 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + Should -Invoke Get-Secret -Times 1 -Exactly -ParameterFilter { + $Vault -eq 'fake-vault' -and $Name -eq 'a-fakeguid-b' + } + + $env:POSHACME_VAULT_SECRET_TEMPLATE = $null + } + } + + It "Tries to Unlock Vault" { + InModuleScope -ModuleName Posh-ACME { + + Mock Write-Error {} + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + Mock Unlock-SecretVault {} #-Name $vaultName -Password $ssPass + $env:POSHACME_VAULT_PASS = 'fakepass' + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) + + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 1 + 'Key' | Should -BeIn $encParam.Keys + ConvertTo-Base64Url $encParam.Key | Should -Be 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + Should -Invoke Unlock-SecretVault -Times 1 -Exactly -ParameterFilter { + $Name -eq 'fake-vault' -and + 'fakepass' -eq [pscredential]::new('a',$Password).GetNetworkCredential().Password + } + + Should -Invoke Get-Secret -Times 1 -Exactly -ParameterFilter { + $Vault -eq 'fake-vault' -and $Name -eq 'poshacme-fakeguid-sskey' + } + + $env:POSHACME_VAULT_PASS = $null + } + } + + It "Handles Get-Secret Failure" { + InModuleScope -ModuleName Posh-ACME { + + Mock Write-Error {} + Mock Get-Secret { throw 'fake exception' } + + $encParam = Get-EncryptionParam -Account (Get-PAAccount) -EA Ignore + + $? | Should -BeFalse + $encParam | Should -BeOfType [hashtable] + $encParam.Count | Should -Be 0 + + } + } + + } +} diff --git a/Tests/Set-AltPluginEncryption.Tests.ps1 b/Tests/Set-AltPluginEncryption.Tests.ps1 new file mode 100644 index 00000000..a079f473 --- /dev/null +++ b/Tests/Set-AltPluginEncryption.Tests.ps1 @@ -0,0 +1,346 @@ +Describe "Set-AltPluginEncryption" { + + BeforeAll { + $env:POSHACME_HOME = 'TestDrive:\' + Import-Module (Join-Path $PSScriptRoot '..\Posh-ACME\Posh-ACME.psd1') + } + + Context "Local Secret - 3 Orders" { + + BeforeAll { + $env:POSHACME_VAULT_NAME = $null + $env:POSHACME_VAULT_PASS = $null + $env:POSHACME_VAULT_SECRET_TEMPLATE = $null + } + + BeforeEach { + Get-ChildItem "$PSScriptRoot\TestFiles\ConfigRoot\" | Copy-Item -Dest 'TestDrive:\' -Recurse -Force + + InModuleScope -ModuleName Posh-ACME { + Mock New-AesKey { 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' } + Mock New-Guid { 'fakeguid' } + Mock Export-PluginArgs {} + } + } + + It "Enables Alt Encryption" { + InModuleScope -ModuleName Posh-ACME { + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Resets Alt Encryption Key" { + InModuleScope -ModuleName Posh-ACME { + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable -Reset + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Disables Alt Encryption" { + InModuleScope -ModuleName Posh-ACME { + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable:$false + + $acct = Get-PAAccount + $acct.sskey | Should -BeNullOrEmpty + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Ignores Enable When Already Enabled" { + InModuleScope -ModuleName Posh-ACME { + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + Should -Invoke Export-PluginArgs -Times 0 + } + } + + It "Ignores Disable When Already Disabled" { + InModuleScope -ModuleName Posh-ACME { + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable:$false + + $acct = Get-PAAccount + $acct.sskey | Should -BeNullOrEmpty + + Should -Invoke Export-PluginArgs -Times 0 + } + } + + It "Doesn't Change Active Account" { + InModuleScope -ModuleName Posh-ACME { + + Mock Update-PAAccount {} + + # set a different account as active + Import-PAConfig + Set-PAAccount -ID acct2 + Get-PAAccount -ID acct1 | Set-AltPluginEncryption -Enable + + # make sure we updated the right account + $acct = Get-PAAccount -ID acct1 + $acct.id | Should -Be 'acct1' + $acct.sskey | Should -Be 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + Should -Invoke Export-PluginArgs -Times 3 + + # make sure original account is still active + (Get-PAAccount).id | Should -Be 'acct2' + } + } + } + + Context "Vault Secret - 3 Orders" { + + BeforeAll { + $env:POSHACME_VAULT_NAME = 'fake-vault' + $env:POSHACME_VAULT_PASS = $null + $env:POSHACME_VAULT_SECRET_TEMPLATE = $null + } + + BeforeEach { + Get-ChildItem "$PSScriptRoot\TestFiles\ConfigRoot\" | Copy-Item -Dest 'TestDrive:\' -Recurse -Force + + InModuleScope -ModuleName Posh-ACME { + Mock New-AesKey { 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' } + Mock New-Guid { 'fakeguid' } + Mock Export-PluginArgs {} + Mock Set-Secret {} + Mock Unlock-SecretVault {} + } + } + + It "Enables Alt Encryption" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret {} + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -ParameterFilter { + $Vault -eq 'fake-vault' -and + $Name -eq 'poshacme-fakeguid-sskey' -and + $Secret -eq 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Enables Alt Encryption - Existing Secret" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -Times 0 + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Ignores Enable When Already Enabled" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'VAULT' -Force + $acct | Add-Member 'VaultGuid' 'fakeguid' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + + Should -Invoke Export-PluginArgs -Times 0 + } + } + + It "Resets Alt Encryption - Old Local" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret {} + + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable -Reset + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -ParameterFilter { + $Vault -eq 'fake-vault' -and + $Name -eq 'poshacme-fakeguid-sskey' -and + $Secret -eq 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Resets Alt Encryption - Old Vault" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'VAULT' -Force + $acct | Add-Member 'VaultGuid' 'fakeguid' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable -Reset + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -ParameterFilter { + $Vault -eq 'fake-vault' -and + $Name -eq 'poshacme-fakeguid-sskey' -and + $Secret -eq 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Disables Alt Encryption - Old Vault" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret { 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } + + # set an old fake sskey on the account + $acct = Get-Content TestDrive:\srvr1\acct1\acct.json -Raw | ConvertFrom-Json + $acct | Add-Member 'sskey' 'VAULT' -Force + $acct | Add-Member 'VaultGuid' 'fakeguid' -Force + $acct | ConvertTo-Json | Out-File TestDrive:\srvr1\acct1\acct.json + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable:$false + + $acct = Get-PAAccount + $acct.sskey | Should -BeNullOrEmpty + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -Times 0 + Should -Invoke Export-PluginArgs -Times 3 + } + } + + It "Falls Back to Local When SecretManagmenet Missing" { + InModuleScope -ModuleName Posh-ACME { + # mimic SecretManagement not being installed + Mock Get-Command {} -ParameterFilter { + $Name -in 'Unlock-SecretVault','Get-Secret' + } + Mock Write-Warning {} + + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + Should -Invoke Set-Secret -Times 0 + Should -Invoke Export-PluginArgs -Times 3 + Should -Invoke Write-Warning -ParameterFilter { + $Message -like 'Unable to save encryption key to secret vault*' + } + } + } + + It "Attempts to Unlock Vault When Specified" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret {} + $env:POSHACME_VAULT_PASS = 'fakepass' + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -ParameterFilter { + $Vault -eq 'fake-vault' -and + $Name -eq 'poshacme-fakeguid-sskey' -and + $Secret -eq 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + Should -Invoke Export-PluginArgs -Times 3 + Should -Invoke Unlock-SecretVault -Times 1 + + $env:POSHACME_VAULT_PASS = $null + } + } + + It "Uses Custom Secret Template When Specified" { + InModuleScope -ModuleName Posh-ACME { + Mock Get-Secret {} + $env:POSHACME_VAULT_SECRET_TEMPLATE = 'mytemplate{0}' + Import-PAConfig + Get-PAAccount | Set-AltPluginEncryption -Enable + + $acct = Get-PAAccount + $acct.sskey | Should -Be 'VAULT' + $acct.VaultGuid | Should -Be 'fakeguid' + + Should -Invoke Set-Secret -ParameterFilter { + $Vault -eq 'fake-vault' -and + $Name -eq 'mytemplatefakeguid' -and + $Secret -eq 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + + Should -Invoke Export-PluginArgs -Times 3 + + $env:POSHACME_VAULT_SECRET_TEMPLATE = $null + } + } + + } + +} diff --git a/docs/Guides/Environment-Variable-Reference.md b/docs/Guides/Environment-Variable-Reference.md index 1a1fb8b5..5954e38b 100644 --- a/docs/Guides/Environment-Variable-Reference.md +++ b/docs/Guides/Environment-Variable-Reference.md @@ -1,11 +1,14 @@ # Environment Variable Reference -Posh-ACME supports a few environment variables that can change the module's behavior. Most of them must be defined prior to the initial import of the module into your PowerShell session. If the module is already loaded when you set them, you can force a reload using `Import-Module Posh-ACME -Force`. +Posh-ACME supports a few environment variables that can change the module's behavior. Some must be defined prior to the initial import of the module into your PowerShell session. If the module is already loaded when you set them, you can force a reload using `Import-Module Posh-ACME -Force`. Here is a reference for what is currently supported. -| Name | Minimum
Posh-ACME Version | Required at
Module Load | Description | -| ---- | :---------------------------: | :-------------------------: | ----------- | -| POSHACME_HOME | 3.2.0 | :white_check_mark: | Change the default config location. ([Guide](Using-an-Alternate-Config-Location.md)) | -| POSHACME_PLUGINS | 4.7.0 | :white_check_mark: | Load custom plugins. ([Guide](Using-Custom-Plugins.md)) | -| POSHACME_SHOW_PROGRESS | 4.11.0 | :x: | Show progress bar during DNS propagation delay timer. Must exist as any non-null or empty value. | +| Name | Minimum
Posh-ACME Version | Required at
Module Load | Description | +| ---- | :---------------------------: | :-------------------------: | ----------- | +| POSHACME_HOME | 3.2.0 | :white_check_mark: | Change the default config location. ([Guide](Using-an-Alternate-Config-Location.md)) | +| POSHACME_PLUGINS | 4.7.0 | :white_check_mark: | Load custom plugins. ([Guide](Using-Custom-Plugins.md)) | +| POSHACME_SHOW_PROGRESS | 4.11.0 | :x: | Show progress bar during DNS propagation delay timer. Must exist as any non-null or empty value. | +| POSHACME_VAULT_NAME | 4.11.0 | :x: | SecretManagement Vault Name to store Posh-ACME secrets. ([Guide](Using-SecretManagement.md)) | +| POSHACME_VAULT_PASS | 4.11.0 | :x: | (Optional) Password required to unlock the vault specified by POSHACME_VAULT_NAME. | +| POSHACME_VAULT_SECRET_TEMPLATE | 4.11.0 | :x: | (Optional) Template used to name the Posh-ACME created secrets. Default is `poshacme-{0}-sskey` and `{0}` is replaced by a per-account vault GUID. | diff --git a/docs/Guides/Using-SecretManagement.md b/docs/Guides/Using-SecretManagement.md new file mode 100644 index 00000000..99654282 --- /dev/null +++ b/docs/Guides/Using-SecretManagement.md @@ -0,0 +1,96 @@ +# Using SecretManagement + +The alternative plugin encryption option added in Posh-ACME 4.0 allows for encrypting secure plugin arguments on disk with better config portability between users/systems and improves the encryption available on non-Windows platforms. The only downside to the feature is that the encryption key was stored with the main config which enables anyone with read access to the files the ability to decrypt the plugin parameters. + +In Posh-ACME 4.11.0, you can now utilize the Microsoft [SecretManagement](https://devblogs.microsoft.com/powershell/secretmanagement-and-secretstore-are-generally-available/) module to store the encryption key in a variety of local, on-prem, and cloud secret stores using supported [vault extensions](https://www.powershellgallery.com/packages?q=Tags%3A%22SecretManagement%22). + +!!! warning + Some vault extensions are read-only and don't allow for creation of new secrets. The vault extensions supported by Posh-ACME must allow for secret creation using arbitrary name values. + +## Prerequisites + +In order to use the SecretManagement feature, you must install both the [Microsoft.PowerShell.SecretManagement](https://www.powershellgallery.com/packages/Microsoft.PowerShell.SecretManagement/) module and an appropriate vault extension module to interface with your preferred secret store. + +You will also need to register a new vault and make note of the vault name. It will be provided to Posh-ACME using the `POSHACME_VAULT_NAME` environment variable. + +### Vault Password + +Some vaults can be configured with a password such that retrieving a secret requires first unlocking the vault with the password. In order to use a vault with Posh-ACME, you have three options. + +- Configure the vault so a password is not required. +- Provide the vault password using the `POSHACME_VAULT_PASS` environment variable. +- Prior to calling Posh-ACME functions, unlock or pre-authenticate to the vault so Posh-ACME can call `Set-Secret` and `Get-Secret` without error. + +### Secret Names and Customization + +Each account configured to use alternative plugin encryption will store a single secret in the vault. The name of each secret will use the following template: + +> `poshacme-{0}-sskey` + +The `{0}` is replaced with a unique GUID value generated for each account the first time the feature is used and stored as a property called `VaultGuid` on the account object. This ensures that using the same vault for multiple accounts does not result in secret naming conflicts. + +You may optionally create an environment variable called `POSHACME_VAULT_SECRET_TEMPLATE` to override the default template. Be sure to include `{0}` in your template string to make sure there are no conflicts between accounts. Also, be aware that some vaults have restrictions on the characters allowed in a secret name. + +## Using a Vault + +### Enable Vault Key Storage + +Ensure the appropriate environment variables are set based on the prerequisites listed above. Then specify the `UseAltPluginEncryption` switch with either `New-PAAccount` for new accounts or `Set-PAAccount` for existing accounts. + +```powershell +# create a new account using vault key storage +New-PAAccount -AcceptTOS -Contact 'me@example.com' -UseAltPluginEncryption -Verbose + +# migrate an existing account to use vault key storage +Set-PAAccount -UseAltPluginEncryption -Verbose +``` + +!!! warning + If `UseAltPluginEncryption` was already enabled for an existing account, you will need to disable it before re-enabling it in order to use vault key storage. + +The verbose output should indicate the name of the secret that was added to the vault specified by your environment variables. You should also be able to list all the secrets associated with Posh-ACME by running the following: + +```powershell +# change the search string if you're using a custom template +Get-SecretInfo -Vault $env:POSHACME_VAULT_NAME -Name '*poshacme*' +``` + +If there was a problem accessing the vault, an warning is thrown and the module falls back to storing the key with the account object. You can verify the current configuration for an account by checking the `VaultGuid` and `sskey` properties on account objects like this: + +```powershell +Get-PAAccount -List | Select-Object id,sskey,VaultGuid +``` + +When `sskey` is null or empty, the account is currently configured to use OS-native encryption. When `sskey` is set to `VAULT` and `VaultGuid` is not empty, the account is configured to use vault key storage. When `sskey` is any other value, the key is being stored with the account object. + +### Disable Vault Key Storage + +To disable vault key storage, use the standard process to disable alternative plugin encryption. + +```powershell +Set-PAAccount -UseAltPluginEncryption:$false +``` + +If you still want to use alternative plugin encryption but without storing the key in a vault, remove your vault related environment variables and then re-enable alternative plugin encryption. + +## Additional Considerations + +### Losing the Vault Key + +If the module is unable to retrieve the key from the vault, it will be unable to decrypt SecureString and PSCredential based plugin arguments and renewals will likely fail. If the key or vault was deleted or is otherwise no longer accessible, you will need to re-configure the plugin arguments for each order associated with the account using `Set-PAOrder`. + +If the vault access disruption is only temporary, the module will be able to continue processing renewals after access is restored. However, new orders or order modifications that configure new plugin arguments will reset the account's config with a new encryption key stored on the account object. It would be difficult to recover the existing plugin arguments on other orders without tedious manual intervention. So this should be avoided if possible. + +### Rotating the Vault Key + +If you believe the encryption key may have been compromised, you can rotate it by using the `ResetAltPluginEncryption` switch on `Set-PAAccount`. + +```powershell +Set-PAAccount -ResetAltPluginEncryption +``` + +Using `ResetAltPluginEncryption` is also an easy way to migrate from storing the key on the account to storing it in a vault. + +### Sharing Configs and Vaults + +In some environments, the Posh-ACME config may be copied to multiple systems. Be wary when doing this using vault key storage. If the systems also share the same remote vault and you rotate the encryption key on one system, it will break the ability to decrypt plugin arguments on the other systems because the other systems won't have re-encrypted their copy of the plugin arguments with the new key. You will need to re-sync the config onto the other systems in order to fix it.