Get-SecureStoreSecret.ps1

<#
.SYNOPSIS
Retrieves a stored SecureStore secret as plain text or PSCredential.

.DESCRIPTION
Get-SecureStoreSecret locates the associated encryption key and secret payload by logical name
or explicit paths, decrypts the payload while enforcing integrity checks, and returns either a
plain text string or PSCredential. Sensitive buffers are zeroed once the value is materialised.

.PARAMETER KeyName
Logical key identifier when using the default folder layout.

.PARAMETER SecretFileName
Secret file name beneath the SecureStore secrets directory.

.PARAMETER KeyPath
Direct path to a .bin key file when bypassing the default layout.

.PARAMETER SecretPath
Direct path to an encrypted secret file when bypassing the default layout.

.PARAMETER FolderPath
Base path of the SecureStore repository. Defaults to the platform-specific location.

.PARAMETER AsCredential
Return the secret as a PSCredential instance instead of plain text.

.PARAMETER UserName
Username associated with the PSCredential output. Ignored unless -AsCredential is specified.

.INPUTS
None. Values are supplied through parameters only.

.OUTPUTS
System.String, System.Management.Automation.PSCredential.

.EXAMPLE
Get-SecureStoreSecret -KeyName 'Database' -SecretFileName 'prod.secret'

Returns the decrypted secret as plain text using the default folder layout.

.EXAMPLE
Get-SecureStoreSecret -KeyPath './bin/Api.bin' -SecretPath './secrets/api.secret' -AsCredential -UserName 'api-user'

Retrieves the secret using explicit file paths and returns a PSCredential.

.NOTES
Decryption throws a friendly error if integrity checks fail or files are missing.

.LINK
New-SecureStoreSecret
#>

function Get-SecureStoreSecret {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [OutputType([string])]
    [OutputType([System.Management.Automation.PSCredential])]
    param(
        [Parameter(ParameterSetName = 'ByName', Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$KeyName,

        [Parameter(ParameterSetName = 'ByName', Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SecretFileName,

        [Parameter(ParameterSetName = 'ByPath', Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$KeyPath,

        [Parameter(ParameterSetName = 'ByPath', Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SecretPath,

        [Parameter(ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [string]$FolderPath = $script:DefaultSecureStorePath,

        [Parameter()]
        [switch]$AsCredential,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$UserName = 'user'
    )

    begin {
        if (-not (Get-Command -Name 'Sync-SecureStoreWorkingDirectory' -ErrorAction SilentlyContinue)) {
            . "$PSScriptRoot/Sync-SecureStoreWorkingDirectory.ps1"
        }
    }

    process {
        $encryptionKey = $null
        $plaintextBytes = $null
        $securePassword = $null
        try {
            $secretCandidates = New-Object System.Collections.Generic.List[string]
            $seenCandidates = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $addCandidate = {
                param([string]$Candidate)

                if ([string]::IsNullOrWhiteSpace($Candidate)) {
                    return
                }

                $resolvedCandidate = [System.IO.Path]::GetFullPath($Candidate)
                if ($seenCandidates.Add($resolvedCandidate)) {
                    [void]$secretCandidates.Add($resolvedCandidate)
                }
            }

            switch ($PSCmdlet.ParameterSetName) {
                'ByPath' {
                    $keyFilePath = Resolve-SecureStorePath -Path $KeyPath -BasePath (Get-Location).Path
                    $explicitSecretPath = Resolve-SecureStorePath -Path $SecretPath -BasePath (Get-Location).Path
                    & $addCandidate $explicitSecretPath

                    $secretDirectory = Split-Path -Path $explicitSecretPath -Parent
                    if ($secretDirectory) {
                        $leaf = Split-Path -Path $secretDirectory -Leaf
                        if ($leaf -and $leaf.Equals('secret', [System.StringComparison]::OrdinalIgnoreCase)) {
                            $preferredDirectory = Join-Path -Path (Split-Path -Path $secretDirectory -Parent) -ChildPath 'secrets'
                            $preferredFromExplicit = Join-Path -Path $preferredDirectory -ChildPath (Split-Path -Path $explicitSecretPath -Leaf)
                            & $addCandidate $preferredFromExplicit
                        }
                    }
                }
                default {
                    $paths = Sync-SecureStoreWorkingDirectory -BasePath $FolderPath

                    if (Test-SecureStorePathLike -Value $KeyName) {
                        $keyFilePath = Resolve-SecureStorePath -Path $KeyName -BasePath $paths.BasePath
                    }
                    else {
                        $keyChild = if ($KeyName.EndsWith('.bin', [System.StringComparison]::OrdinalIgnoreCase)) { $KeyName } else { "$KeyName.bin" }
                        $keyFilePath = Join-Path -Path $paths.BinPath -ChildPath $keyChild
                    }

                    if (Test-SecureStorePathLike -Value $SecretFileName) {
                        $explicitSecretPath = Resolve-SecureStorePath -Path $SecretFileName -BasePath $paths.BasePath
                        $preferredSecretPath = ConvertTo-SecureStorePreferredSecretPath -Path $explicitSecretPath -PreferredSecretDir $paths.SecretPath -LegacySecretDir $paths.LegacySecretPath
                        & $addCandidate $preferredSecretPath
                        & $addCandidate $explicitSecretPath

                        if ($paths.LegacySecretPath) {
                            $relativeSecretPath = Get-SecureStoreRelativePath -BasePath $paths.SecretPath -TargetPath $preferredSecretPath
                            if ($null -ne $relativeSecretPath) {
                                $legacyCandidate = if ([string]::IsNullOrWhiteSpace($relativeSecretPath)) { $paths.LegacySecretPath } else { Join-Path -Path $paths.LegacySecretPath -ChildPath $relativeSecretPath }
                                & $addCandidate $legacyCandidate
                            }
                        }
                    }
                    else {
                        $preferredSecretPath = Join-Path -Path $paths.SecretPath -ChildPath $SecretFileName
                        & $addCandidate $preferredSecretPath

                        if ($paths.LegacySecretPath) {
                            $legacyCandidate = Join-Path -Path $paths.LegacySecretPath -ChildPath $SecretFileName
                            & $addCandidate $legacyCandidate
                        }
                    }
                }
            }

            if (-not (Test-Path -LiteralPath $keyFilePath)) {
                throw [System.IO.FileNotFoundException]::new('The encryption key file could not be located.', $keyFilePath)
            }

            $secretFilePath = $null
            foreach ($candidate in $secretCandidates) {
                if (Test-Path -LiteralPath $candidate) {
                    $secretFilePath = $candidate
                    break
                }
            }

            if (-not $secretFilePath) {
                $fallbackTarget = if ($secretCandidates.Count -gt 0) { $secretCandidates[0] } else { $SecretFileName }
                throw [System.IO.FileNotFoundException]::new('The secret file could not be located.', $fallbackTarget)
            }

            # Load the key and encrypted payload into memory for decryption.
            $encryptionKey = Read-SecureStoreByteArray -Path $keyFilePath
            $encryptedPassword = Read-SecureStoreText -Path $secretFilePath -Encoding ([System.Text.Encoding]::UTF8)

            $plaintextBytes = Unprotect-SecureStoreSecret -Payload $encryptedPassword -MasterKey $encryptionKey

            $chars = [System.Text.Encoding]::UTF8.GetChars($plaintextBytes)
            try {
                # Reconstruct a SecureString to avoid exposing the password longer than necessary.
                $securePassword = New-Object System.Security.SecureString
                foreach ($char in $chars) {
                    $securePassword.AppendChar($char)
                }
                $securePassword.MakeReadOnly()
            }
            finally {
                [Array]::Clear($chars, 0, $chars.Length)
            }

            if ($AsCredential.IsPresent) {
                # Return a copy so the caller can dispose the credential without affecting internal buffers.
                return New-Object System.Management.Automation.PSCredential($UserName, $securePassword.Copy())
            }

            $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
            try {
                return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
            }
            finally {
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
            }
        }
        catch {
            throw [System.InvalidOperationException]::new('Failed to retrieve the requested secret.', $_.Exception)
        }
        finally {
            if ($securePassword) {
                $securePassword.Dispose()
            }
            if ($plaintextBytes) {
                [Array]::Clear($plaintextBytes, 0, $plaintextBytes.Length)
            }
            if ($encryptionKey) {
                [Array]::Clear($encryptionKey, 0, $encryptionKey.Length)
            }
        }
    }
}