diff --git a/resources/windows_secretstore/.project.data.json b/resources/windows_secretstore/.project.data.json new file mode 100644 index 000000000..396865f34 --- /dev/null +++ b/resources/windows_secretstore/.project.data.json @@ -0,0 +1,10 @@ +{ + "Name": "WindowsSecretStore", + "Kind": "Resource", + "CopyFiles": { + "Windows": [ + "windows_secretstore.ps1", + "windows_secretstore.dsc.resource.json" + ] + } +} diff --git a/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 new file mode 100644 index 000000000..b35cd2fca --- /dev/null +++ b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows SecretStore config tests' -Skip:(!$IsWindows) { + BeforeAll { + Set-StrictMode -Version Latest + + function Install-TestModule { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | Select-Object -First 1) { + return + } + + $installPsResourceCommand = Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue + if (-not $installPsResourceCommand) { + throw "Install-PSResource is required to install test dependency module '$Name'." + Install-PSResource -Name $Name -Scope CurrentUser -TrustRepository -Quiet -ErrorAction Stop + } + Install-PSResource -Name $Name -Scope CurrentUser -TrustRepository -Quiet -ErrorAction Stop + } + + foreach ($moduleName in @( + 'Pester', + 'Microsoft.PowerShell.SecretManagement', + 'Microsoft.PowerShell.SecretStore' + )) { + Install-TestModule -Name $moduleName + } + + Import-Module Microsoft.PowerShell.SecretManagement -Force -ErrorAction Stop + Import-Module Microsoft.PowerShell.SecretStore -Force -ErrorAction Stop + + function Reset-SecretStoreForTest { + Reset-SecretStore -Authentication None -Interaction None -PasswordTimeout -1 -Force -Confirm:$false -ErrorAction Stop | Out-Null + } + + # Inline DSC config: none-auth (unattended automation) + $script:configNone = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Configure SecretStore for unattended automation + type: Microsoft.PowerShell/WindowsSecretStore + properties: + authentication: None + passwordTimeout: -1 + interaction: None + scope: CurrentUser +'@ + + # Inline DSC config: password-auth with secureString parameter + $script:configPassword = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + SecretPassword: + type: secureString + defaultValue: TestSecretValue +resources: + - name: Configure SecretStore for unattended automation + type: Microsoft.PowerShell/WindowsSecretStore + properties: + authentication: Password + passwordTimeout: -1 + interaction: None + scope: CurrentUser + password: "[parameters('SecretPassword')]" +'@ + } + + BeforeEach { + Reset-SecretStoreForTest + } + + Context 'dsc config set then test (none-auth)' { + It 'applies the none-auth configuration via dsc config set' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + } + + It 'reports desired state via dsc config test' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + + $out = dsc config test -i $script:configNone 2>"$TestDrive/test.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/test.stderr") + + $result = $out.results[0].result + $result.inDesiredState | Should -BeTrue + } + + It 'returns current state via dsc config get' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + + $out = dsc config get -i $script:configNone 2>"$TestDrive/get.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/get.stderr") + + $actualState = $out.results[0].result.actualState + $actualState.authentication | Should -BeExactly 'None' + $actualState.interaction | Should -BeExactly 'None' + $actualState.passwordTimeout | Should -Be -1 + $actualState.scope | Should -BeExactly 'CurrentUser' + } + } + + Context 'dsc config set then test (password-auth)' { + It 'applies the password-auth configuration via dsc config set' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + } + + It 'reports desired state via dsc config test' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + + $out = dsc config test -i $script:configPassword 2>"$TestDrive/test.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/test.stderr") + + $result = $out.results[0].result + $result.inDesiredState | Should -BeTrue + } + + It 'returns current state via dsc config get' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + + $out = dsc config get -i $script:configPassword 2>"$TestDrive/get.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/get.stderr") + + $actualState = $out.results[0].result.actualState + $actualState.authentication | Should -BeExactly 'Password' + $actualState.interaction | Should -BeExactly 'None' + $actualState.passwordTimeout | Should -Be -1 + $actualState.scope | Should -BeExactly 'CurrentUser' + } + } +} \ No newline at end of file diff --git a/resources/windows_secretstore/windows_secretstore.dsc.resource.json b/resources/windows_secretstore/windows_secretstore.dsc.resource.json new file mode 100644 index 000000000..f86fbba5c --- /dev/null +++ b/resources/windows_secretstore/windows_secretstore.dsc.resource.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.PowerShell/WindowsSecretStore", + "version": "0.1.0", + "description": "Manages the configuration of the Microsoft.PowerShell.SecretStore vault (authentication mode, password timeout, interaction policy, and scope).", + "tags": [ + "Windows", + "PowerShell", + "SecretStore", + "SecretManagement", + "Security" + ], + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "get": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Get" + ], + "input": "stdin" + }, + "set": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Set" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Test" + ], + "input": "stdin", + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Operation failed (module missing or cmdlet error)" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Microsoft.PowerShell/WindowsSecretStore", + "description": "Properties that describe the configuration of the Microsoft.PowerShell.SecretStore vault.", + "type": "object", + "properties": { + "authentication": { + "title": "Authentication", + "description": "Specifies the SecretStore authentication mode for vault access. Allowed values are 'None', 'Prompt', and 'Password'.", + "type": "string", + "enum": [ + "None", + "Prompt", + "Password" + ] + }, + "passwordTimeout": { + "title": "Password Timeout", + "description": "Specifies how many seconds the vault remains unlocked after successful authentication. Use -1 to disable the timeout (vault stays unlocked indefinitely for the session).", + "type": "integer", + "minimum": -1 + }, + "interaction": { + "title": "Interaction", + "description": "Controls whether the vault is allowed to prompt the user for interaction (e.g., password input). This DSC resource runs non-interactively and only supports 'None'.", + "type": "string", + "enum": [ + "None" + ] + }, + "scope": { + "title": "Scope", + "description": "Specifies whether the SecretStore vault is configured for the current user ('CurrentUser') or all users on the machine ('AllUsers'). Changing scope may require elevated privileges.", + "type": "string", + "enum": [ + "CurrentUser", + "AllUsers" + ] + }, + "_inDesiredState": { + "title": "In Desired State", + "description": "Indicates whether the resource is in the desired state. Populated by the Test operation.", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/resources/windows_secretstore/windows_secretstore.ps1 b/resources/windows_secretstore/windows_secretstore.ps1 new file mode 100644 index 000000000..e6c8058fe --- /dev/null +++ b/resources/windows_secretstore/windows_secretstore.ps1 @@ -0,0 +1,433 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + DSC v3 resource script for managing Microsoft.PowerShell.SecretStore configuration. + +.DESCRIPTION + Implements Get, Set, and Test operations for the SecretStore vault configuration. + Requires the Microsoft.PowerShell.SecretStore module to be installed. + +.PARAMETER Operation + The DSC operation to perform: Get, Set, or Test. + +.PARAMETER jsonInput + JSON string received via pipeline containing the desired state properties. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('Get', 'Set', 'Test')] + [string]$Operation, + + [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)] + [string]$jsonInput +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-DscTrace { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] + [string]$Level, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message + ) + + $trace = @{ $Level.ToLower() = $Message } | ConvertTo-Json -Compress + $host.ui.WriteErrorLine($trace) +} + +function Assert-ModuleAvailable { + param([string]$ModuleName) + + if (-not (Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue | + Select-Object -First 1)) { + Write-DscTrace -Level Error -Message ( + "Required module '$ModuleName' is not installed. " + + "Install it with: Install-Module -Name $ModuleName -Repository PSGallery -Force" + ) + exit 1 + } +} + +function Test-IsNonInteractiveSession { + <# + .SYNOPSIS + Detects if the current PowerShell process was started with -NonInteractive. + #> + try { + $commandLineArgs = [Environment]::GetCommandLineArgs() + foreach ($arg in $commandLineArgs) { + if ($arg -ieq '-NonInteractive') { + return $true + } + } + } + catch { + # If detection fails, default to interactive assumptions. + } + + return $false +} + +function ConvertTo-SecretStoreSecureString { + param( + [AllowNull()] + [AllowEmptyString()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [System.Security.SecureString]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary] -and $Value.Contains('secureString')) { + $Value = $Value['secureString'] + } + elseif ($Value.PSObject.Properties['secureString']) { + $Value = $Value.secureString + } + + $plaintext = [string]$Value + if ([string]::IsNullOrEmpty($plaintext)) { + return $null + } + + return (ConvertTo-SecureString -String $plaintext -AsPlainText -Force) +} + +function Get-CurrentState { + <# + .SYNOPSIS + Returns a hashtable representing the current SecretStore configuration. + #> + param( + [switch]$SuppressNonInteractiveError, + + [AllowNull()] + [System.Security.SecureString]$Password + ) + + if ($null -ne $Password) { + try { + Unlock-SecretStore -Password $Password -ErrorAction Stop | Out-Null + } + catch { + # If the store is currently configured for None authentication, unlocking is not required. + # This happens when Test/Get is called with a password to verify a desired Password-auth state + # but the store hasn't been reconfigured yet (still in None mode). + if ($_.ToString() -match 'not configured to use a password') { + # No unlock needed; fall through to Get-SecretStoreConfiguration. + } + else { + Write-DscTrace -Level Error -Message "Failed to unlock SecretStore with the provided password: $_" + exit 1 + } + } + } + + try { + $config = Get-SecretStoreConfiguration -ErrorAction Stop + return [ordered]@{ + authentication = $config.Authentication.ToString() + passwordTimeout = [int]$config.PasswordTimeout + interaction = $config.Interaction.ToString() + scope = $config.Scope.ToString() + } + } + catch { + if ($_.ToString() -match 'NonInteractive mode|require interactive input') { + if ($SuppressNonInteractiveError) { + return [ordered]@{ + authentication = 'None' + passwordTimeout = 900 + interaction = 'None' + scope = 'CurrentUser' + requiresInteractiveInput = $true + } + } + + Write-DscTrace -Level Error -Message ( + "SecretStore is configured to require interactive input. " + + "This DSC resource runs PowerShell with -NonInteractive, so prompts are not allowed. " + + "Reconfigure SecretStore in an interactive session first, for example: " + + "Set-SecretStoreConfiguration -Authentication None -Interaction None -PasswordTimeout -1 -Confirm:`$false" + ) + exit 1 + } + + Write-DscTrace -Level Error -Message "Failed to retrieve SecretStore configuration: $_" + exit 1 + } +} + +function Ensure-SecretStoreVaultRegistered { + <# + .SYNOPSIS + Ensures the SecretStore vault is registered before configuration changes. + #> + try { + $vault = Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue + if ($null -eq $vault) { + Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault -ErrorAction Stop + Write-DscTrace -Level Info -Message 'Registered SecretStore vault.' + } + } + catch { + Write-DscTrace -Level Error -Message "Failed to register SecretStore vault: $_" + exit 1 + } +} + +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +Assert-ModuleAvailable -ModuleName 'Microsoft.PowerShell.SecretStore' + +try { + Import-Module Microsoft.PowerShell.SecretStore -ErrorAction Stop +} +catch { + Write-DscTrace -Level Error -Message "Failed to import Microsoft.PowerShell.SecretStore: $_" + exit 1 +} + +# --------------------------------------------------------------------------- +# Parse input +# --------------------------------------------------------------------------- + +$desired = $null +try { + $desired = $jsonInput | ConvertFrom-Json -AsHashtable -ErrorAction Stop +} +catch { + Write-DscTrace -Level Error -Message "Failed to parse JSON input: $_" + exit 1 +} + +if ($null -eq $desired) { + $desired = @{} +} + +# --------------------------------------------------------------------------- +# Operations +# --------------------------------------------------------------------------- + +switch ($Operation) { + 'Get' { + try { + $suppressNonInteractiveError = Test-IsNonInteractiveSession + $password = $null + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + } + + Get-CurrentState -SuppressNonInteractiveError:$suppressNonInteractiveError -Password $password | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Get operation failed: $_" + exit 1 + } + } + + 'Set' { + try { + $setParams = @{ Confirm = $false } + $password = $null + + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + if ($null -eq $password) { + Write-DscTrace -Level Error -Message 'The password property was provided but is empty. Provide a non-empty SecureString value.' + exit 1 + } + + $setParams['Password'] = $password + $setParams['Authentication'] = 'Password' + } + + if ($desired.ContainsKey('authentication') -and -not $setParams.ContainsKey('Authentication')) { + $setParams['Authentication'] = $desired['authentication'] + } + if ($desired.ContainsKey('passwordTimeout')) { $setParams['PasswordTimeout'] = [int]$desired['passwordTimeout'] } + if ($desired.ContainsKey('interaction')) { $setParams['Interaction'] = $desired['interaction'] } + if ($desired.ContainsKey('scope')) { $setParams['Scope'] = $desired['scope'] } + + if ($setParams.ContainsKey('Authentication') -and $setParams['Authentication'] -eq 'Password' -and -not $setParams.ContainsKey('Password')) { + Write-DscTrace -Level Error -Message ( + 'Authentication was set to Password but no password property was provided. Supply password as a DSC SecureString parameter.' + ) + exit 1 + } + + if ($setParams.Count -eq 1) { + # Only Confirm was in params - nothing to change + Write-DscTrace -Level Info -Message 'No configurable properties specified; nothing to set.' + } + else { + Ensure-SecretStoreVaultRegistered + try { + Set-SecretStoreConfiguration @setParams -ErrorAction Stop + } + catch { + # Two known cases where Set-SecretStoreConfiguration cannot proceed and we fall back to Reset-SecretStore: + # 1. The host is non-interactive so the module cannot prompt. + # 2. We are transitioning to Password auth but the store is currently in None auth mode; + # the module tries to Unlock-SecretStore with the new password before reconfiguring, + # which fails because the store is not yet in Password mode. + $isNonInteractive = $_.ToString() -match 'NonInteractive mode|require interactive input' + $isAuthMismatch = $_.ToString() -match 'not configured to use a password' + + if ($isNonInteractive -or $isAuthMismatch) { + $resetParams = @{ + Force = $true + Confirm = $false + } + + if ($setParams.ContainsKey('Authentication')) { $resetParams['Authentication'] = $setParams['Authentication'] } + if ($setParams.ContainsKey('Password')) { $resetParams['Password'] = $setParams['Password'] } + if ($setParams.ContainsKey('PasswordTimeout')) { $resetParams['PasswordTimeout'] = $setParams['PasswordTimeout'] } + if ($setParams.ContainsKey('Interaction')) { $resetParams['Interaction'] = $setParams['Interaction'] } + if ($setParams.ContainsKey('Scope')) { $resetParams['Scope'] = $setParams['Scope'] } + + $reason = if ($isAuthMismatch) { + 'Store is in None-auth mode; transitioning to Password auth requires a full reset.' + } else { + 'SecretStore requires interactive input.' + } + + $allowDestructiveReset = $false + if ($null -ne $env:DSC_SECRETSTORE_ALLOW_RESET) { + $allowDestructiveReset = $env:DSC_SECRETSTORE_ALLOW_RESET -match '^(?i:true|1|yes)$' + } + if (-not $allowDestructiveReset) { + $errorMessage = ( + "$reason Reset-SecretStore was blocked because it is destructive and can remove existing secrets. " + + "To allow this fallback intentionally, set the environment variable " + + "'DSC_SECRETSTORE_ALLOW_RESET' to 'true' before applying the configuration." + ) + Write-DscTrace -Level Error -Message $errorMessage + throw $errorMessage + } + + Write-DscTrace -Level Warn -Message ( + "$reason Proceeding with Reset-SecretStore because explicit opt-in was provided via " + + "DSC_SECRETSTORE_ALLOW_RESET. This operation is destructive and may remove existing secrets." + ) + Reset-SecretStore @resetParams -ErrorAction Stop + } + else { + throw + } + } + Write-DscTrace -Level Info -Message 'SecretStore configuration updated successfully.' + } + + # Return the resulting state. Pass the password so Unlock-SecretStore can open the vault if + # the store was just transitioned to Password auth. Get-CurrentState silently skips the unlock + # when the store is still in None-auth mode (see the 'not configured to use a password' guard). + $suppressNonInteractiveError = Test-IsNonInteractiveSession + Get-CurrentState -SuppressNonInteractiveError:$suppressNonInteractiveError -Password $password | ConvertTo-Json -Compress + } + catch { + if ($_.ToString() -match 'NonInteractive mode|require interactive input') { + Write-DscTrace -Level Error -Message ( + "Set operation requires interactive input with the current SecretStore settings. " + + "Run this once in an interactive PowerShell session to allow unattended DSC runs: " + + "Set-SecretStoreConfiguration -Authentication None -Interaction None -PasswordTimeout -1 -Confirm:`$false" + ) + exit 1 + } + + Write-DscTrace -Level Error -Message "Set operation failed: $_" + exit 1 + } + } + + 'Test' { + try { + $password = $null + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + } + + $current = Get-CurrentState -SuppressNonInteractiveError -Password $password + $inDesiredState = $true + $normalizedDesiredAuthentication = $null + + if ($desired.ContainsKey('password')) { + $normalizedDesiredAuthentication = 'Password' + } + elseif ($desired.ContainsKey('authentication')) { + $normalizedDesiredAuthentication = $desired['authentication'] + } + + if ($current['requiresInteractiveInput']) { + Write-DscTrace -Level Info -Message ( + 'SecretStore currently requires interactive input, so it is not in the desired state for unattended DSC execution.' + ) + $inDesiredState = $false + } + + $propertyMap = @{ + authentication = 'authentication' + passwordTimeout = 'passwordTimeout' + interaction = 'interaction' + scope = 'scope' + } + + foreach ($key in $propertyMap.Keys) { + $hasDesiredValue = $desired.ContainsKey($key) + $desiredValue = $null + + if ($key -eq 'authentication' -and $null -ne $normalizedDesiredAuthentication) { + $hasDesiredValue = $true + $desiredValue = $normalizedDesiredAuthentication + } + elseif ($hasDesiredValue) { + $desiredValue = $desired[$key] + } + + if ($hasDesiredValue) { + $currentValue = $current[$key] + + if ($current['requiresInteractiveInput']) { + continue + } + + # Normalize integer comparison + if ($key -eq 'passwordTimeout') { + $desiredValue = [int]$desiredValue + $currentValue = [int]$currentValue + } + + if ($currentValue -ne $desiredValue) { + Write-DscTrace -Level Info -Message ( + "Property '$key' is not in desired state. " + + "Current: '$currentValue', Desired: '$desiredValue'." + ) + $inDesiredState = $false + } + } + } + + $current['_inDesiredState'] = $inDesiredState + $current | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Test operation failed: $_" + exit 1 + } + } +} \ No newline at end of file