SecretManagement.LinuxKeyring.Extension/SecretManagement.LinuxKeyring.Extension.psm1
|
function ConvertFrom-SecureStringPlainText { param( [Parameter(Mandatory)] [System.Security.SecureString] $SecureString ) $ptr = [System.IntPtr]::Zero try { $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } finally { if ($ptr -ne [System.IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } } function Resolve-SecretPayloadText { param( [Parameter(Mandatory)] [object] $RawValue ) if ($null -eq $RawValue) { return $null } $payloadText = if ($RawValue -is [System.Array]) { ($RawValue | ForEach-Object { [string]$_ }) -join [System.Environment]::NewLine } else { [string]$RawValue } return $payloadText.Trim("`0", "`r", "`n", " ", "`t") } function ConvertTo-SecretPayload { param( [Parameter(Mandatory)] [object] $Secret ) $payloadPrefix = "SM-LINUXKEYRING::" $payloadObject = $null if ($Secret -is [pscredential]) { $payloadObject = [ordered]@{ SchemaVersion = 1 Type = "PSCredential" UserName = $Secret.UserName Password = ConvertFrom-SecureStringPlainText -SecureString $Secret.Password } } elseif ($Secret -is [System.Security.SecureString]) { $payloadObject = [ordered]@{ SchemaVersion = 1 Type = "SecureString" Value = ConvertFrom-SecureStringPlainText -SecureString $Secret } } elseif ($Secret -is [string]) { $payloadObject = [ordered]@{ SchemaVersion = 1 Type = "String" Value = $Secret } } else { $payloadObject = [ordered]@{ SchemaVersion = 1 Type = "String" Value = $Secret.ToString() } } return $payloadPrefix + ($payloadObject | ConvertTo-Json -Compress) } function ConvertFrom-SecretPayload { param( [Parameter(Mandatory)] [string] $Payload ) $normalizedPayload = Resolve-SecretPayloadText -RawValue $Payload $payloadPrefix = "SM-LINUXKEYRING::" if (-not $normalizedPayload.StartsWith($payloadPrefix, [System.StringComparison]::Ordinal)) { # Backward compatibility with older versions that stored plaintext only. # Also tolerate JSON payloads that may not include our prefix. try { $secretObject = $normalizedPayload | ConvertFrom-Json -ErrorAction Stop if ($secretObject.Type -eq "PSCredential") { $password = ConvertTo-SecureString -String $secretObject.Password -AsPlainText -Force return [pscredential]::new($secretObject.UserName, $password) } if ($secretObject.Type -eq "String") { return [string]$secretObject.Value } if ($secretObject.Type -eq "SecureString") { return [System.Security.SecureString](ConvertTo-SecureString -String $secretObject.Value -AsPlainText -Force) } } catch { } return [System.Security.SecureString](ConvertTo-SecureString -String $normalizedPayload -AsPlainText -Force) } $json = $normalizedPayload.Substring($payloadPrefix.Length) $secretObject = $json | ConvertFrom-Json switch ($secretObject.Type) { "PSCredential" { $password = ConvertTo-SecureString -String $secretObject.Password -AsPlainText -Force return [pscredential]::new($secretObject.UserName, $password) } "String" { return [string]$secretObject.Value } "SecureString" { return [System.Security.SecureString](ConvertTo-SecureString -String $secretObject.Value -AsPlainText -Force) } default { return [System.Security.SecureString](ConvertTo-SecureString -String $normalizedPayload -AsPlainText -Force) } } } function Get-SecretTypeFromPayload { param( [Parameter(Mandatory)] [string] $Payload ) $normalizedPayload = Resolve-SecretPayloadText -RawValue $Payload $payloadPrefix = "SM-LINUXKEYRING::" if (-not $normalizedPayload.StartsWith($payloadPrefix, [System.StringComparison]::Ordinal)) { try { $secretObject = $normalizedPayload | ConvertFrom-Json -ErrorAction Stop switch ($secretObject.Type) { "PSCredential" { return [Microsoft.PowerShell.SecretManagement.SecretType]::PSCredential } "String" { return [Microsoft.PowerShell.SecretManagement.SecretType]::String } default { return [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString } } } catch { return [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString } } try { $json = $normalizedPayload.Substring($payloadPrefix.Length) $secretObject = $json | ConvertFrom-Json switch ($secretObject.Type) { "PSCredential" { return [Microsoft.PowerShell.SecretManagement.SecretType]::PSCredential } "String" { return [Microsoft.PowerShell.SecretManagement.SecretType]::String } default { return [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString } } } catch { return [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString } } function ConvertTo-ExportSecretRecord { param( [Parameter(Mandatory)] [string] $Name, [Parameter(Mandatory)] [object] $Secret ) if ($Secret -is [pscredential]) { return [ordered]@{ Name = $Name Type = "PSCredential" UserName = $Secret.UserName Password = ConvertFrom-SecureStringPlainText -SecureString $Secret.Password } } if ($Secret -is [System.Security.SecureString]) { return [ordered]@{ Name = $Name Type = "SecureString" Value = ConvertFrom-SecureStringPlainText -SecureString $Secret } } if ($Secret -is [string]) { return [ordered]@{ Name = $Name Type = "String" Value = $Secret } } return [ordered]@{ Name = $Name Type = "Json" Value = ($Secret | ConvertTo-Json -Depth 10 -Compress) } } function ConvertFrom-ImportSecretRecord { param( [Parameter(Mandatory)] [pscustomobject] $Record ) switch ($Record.Type) { "PSCredential" { $password = ConvertTo-SecureString -String $Record.Password -AsPlainText -Force return [pscredential]::new($Record.UserName, $password) } "SecureString" { return [System.Security.SecureString](ConvertTo-SecureString -String $Record.Value -AsPlainText -Force) } "String" { return [string]$Record.Value } "Json" { return ($Record.Value | ConvertFrom-Json) } default { throw "Unsupported secret record type '$($Record.Type)' for secret '$($Record.Name)'." } } } function Get-SecretToolAttributeArgs { param( [Parameter(Mandatory)] [string] $Name, [string] $VaultName, [switch] $IncludeVaultName ) $args = @("service", "powershell-vault", "account", $Name) if ($IncludeVaultName -and -not [string]::IsNullOrWhiteSpace($VaultName)) { $args += @("vault", $VaultName) } return $args } function Invoke-SecretToolLookup { param( [Parameter(Mandatory)] [string] $Name, [string] $VaultName ) $scopedLookupArgs = @("lookup") + (Get-SecretToolAttributeArgs -Name $Name -VaultName $VaultName -IncludeVaultName) $scopedResult = & secret-tool @scopedLookupArgs 2>$null $scopedPayload = Resolve-SecretPayloadText -RawValue $scopedResult if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($scopedPayload)) { return $scopedPayload } if (-not [string]::IsNullOrWhiteSpace($VaultName)) { $legacyLookupArgs = @("lookup") + (Get-SecretToolAttributeArgs -Name $Name) $legacyResult = & secret-tool @legacyLookupArgs 2>$null $legacyPayload = Resolve-SecretPayloadText -RawValue $legacyResult if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($legacyPayload)) { return $legacyPayload } } return $null } function Invoke-SecretToolClearAll { param( [Parameter(Mandatory)] [string[]] $AttributeArgs ) $removedAny = $false while ($true) { & secret-tool clear @AttributeArgs 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { break } $removedAny = $true } return $removedAny } function Get-Secret { [CmdletBinding()] param( [string] $Name, [string] $VaultName, [hashtable] $AdditionalParameters ) try { $payload = Invoke-SecretToolLookup -Name $Name -VaultName $VaultName if (-not [string]::IsNullOrWhiteSpace($payload)) { return ConvertFrom-SecretPayload -Payload $payload } } catch { Write-Error "Failed to retrieve secret '$Name': $($_.Exception.Message)" } return $null } function Set-Secret { [CmdletBinding()] param( [string] $Name, [object] $Secret, [string] $VaultName, [hashtable] $AdditionalParameters ) try { $secretText = ConvertTo-SecretPayload -Secret $Secret $scopedAttributeArgs = Get-SecretToolAttributeArgs -Name $Name -VaultName $VaultName -IncludeVaultName $legacyAttributeArgs = Get-SecretToolAttributeArgs -Name $Name # Clear stale entries first so a subsequent lookup always returns the latest value. $null = Invoke-SecretToolClearAll -AttributeArgs $scopedAttributeArgs if (-not [string]::IsNullOrWhiteSpace($VaultName)) { $null = Invoke-SecretToolClearAll -AttributeArgs $legacyAttributeArgs } $storeArgs = @("store", "--label", "PowerShell Secret: $Name") + $scopedAttributeArgs $secretText | & secret-tool @storeArgs return ($LASTEXITCODE -eq 0) } catch { Write-Error "Failed to store secret '$Name': $($_.Exception.Message)" return $false } } function Select-ItemsFromConsoleGrid { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $InputObject, [Parameter(Mandatory)] [string] $Title ) $gridCommand = Get-Command Out-ConsoleGridView -ErrorAction SilentlyContinue if (-not $gridCommand) { throw "Out-ConsoleGridView command not found. Install Microsoft.PowerShell.ConsoleGuiTools to use -Selected." } return @($InputObject | Out-ConsoleGridView -Title $Title -OutputMode Multiple) } function Export-Secret { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ParameterSetName = "ByName")] [string[]] $Name, [Parameter(Mandatory, ParameterSetName = "All")] [switch] $All, [Parameter(Mandatory, ParameterSetName = "Selected")] [switch] $Selected, [Parameter(Mandatory)] [string] $Vault, [Parameter(Mandatory)] [string] $Path ) Write-Warning "Exported secrets are stored as plaintext JSON. Protect this file appropriately." if (-not $PSCmdlet.ShouldContinue("Continue exporting secrets to plaintext file '$Path'?", "Confirm plaintext secret export")) { Write-Verbose "Export cancelled by user." return } try { $secretNames = switch ($PSCmdlet.ParameterSetName) { "All" { @(Microsoft.PowerShell.SecretManagement\Get-SecretInfo -Vault $Vault -ErrorAction Stop | ForEach-Object { $_.Name }) } "ByName" { $Name } "Selected" { $candidates = @(Microsoft.PowerShell.SecretManagement\Get-SecretInfo -Vault $Vault -ErrorAction Stop) $selectedSecrets = Select-ItemsFromConsoleGrid -InputObject $candidates -Title "Select secrets to export from vault '$Vault'" @($selectedSecrets | ForEach-Object { $_.Name }) } } if (-not $secretNames -or $secretNames.Count -eq 0) { Write-Verbose "No secrets selected for export." return } $records = @() foreach ($secretName in $secretNames) { $secretValue = Microsoft.PowerShell.SecretManagement\Get-Secret -Name $secretName -Vault $Vault -ErrorAction Stop $records += [pscustomobject](ConvertTo-ExportSecretRecord -Name $secretName -Secret $secretValue) } $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $directory = Split-Path -Path $resolvedPath -Parent if ($directory -and -not (Test-Path -Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $records | ConvertTo-Json -Depth 10 | Set-Content -Path $resolvedPath -Encoding UTF8 return Get-Item -Path $resolvedPath } catch { Write-Error "Failed to export secrets: $($_.Exception.Message)" } } function Import-Secret { [CmdletBinding()] param( [Parameter(Mandatory, ParameterSetName = "ByName")] [string[]] $Name, [Parameter(Mandatory, ParameterSetName = "All")] [switch] $All, [Parameter(Mandatory, ParameterSetName = "Selected")] [switch] $Selected, [Parameter(Mandatory)] [string] $Vault, [Parameter(Mandatory)] [string] $Path ) try { if (-not (Test-Path -Path $Path)) { throw "Import file '$Path' does not exist." } $jsonText = Get-Content -Path $Path -Raw -ErrorAction Stop $records = @($jsonText | ConvertFrom-Json -ErrorAction Stop) switch ($PSCmdlet.ParameterSetName) { "ByName" { $nameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($n in $Name) { $null = $nameSet.Add($n) } $records = @($records | Where-Object { $nameSet.Contains($_.Name) }) } "Selected" { $records = Select-ItemsFromConsoleGrid -InputObject $records -Title "Select secrets to import from '$Path'" } } if (-not $records -or $records.Count -eq 0) { Write-Verbose "No secrets selected for import." return 0 } foreach ($record in $records) { $secretObject = ConvertFrom-ImportSecretRecord -Record $record $null = Microsoft.PowerShell.SecretManagement\Set-Secret -Name $record.Name -Vault $Vault -Secret $secretObject -ErrorAction Stop } return $records.Count } catch { Write-Error "Failed to import secrets: $($_.Exception.Message)" } } function Remove-Secret { [CmdletBinding()] param( [string] $Name, [string] $VaultName, [hashtable] $AdditionalParameters ) try { $scopedAttributeArgs = Get-SecretToolAttributeArgs -Name $Name -VaultName $VaultName -IncludeVaultName $legacyAttributeArgs = Get-SecretToolAttributeArgs -Name $Name $removed = Invoke-SecretToolClearAll -AttributeArgs $scopedAttributeArgs if (-not [string]::IsNullOrWhiteSpace($VaultName)) { if (Invoke-SecretToolClearAll -AttributeArgs $legacyAttributeArgs) { $removed = $true } } return $removed } catch { Write-Error "Failed to remove secret '$Name': $($_.Exception.Message)" return $false } } function Get-SecretInfo { [CmdletBinding()] param( [string] $Filter, [string] $VaultName, [hashtable] $AdditionalParameters ) try { $secrets = @() $seenSecretNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Capture both stdout and stderr to get all output from secret-tool $searchOutput = $(& secret-tool search --all service "powershell-vault" 2>&1) if ($searchOutput) { $searchOutput | ForEach-Object { if ($_ -match "^attribute\.account = (.+)$") { $secretName = $Matches[1] if ((-not $Filter -or $secretName -like "*$Filter*") -and $seenSecretNames.Add($secretName)) { $payload = Invoke-SecretToolLookup -Name $secretName -VaultName $VaultName $secretType = if (-not [string]::IsNullOrWhiteSpace($payload)) { Get-SecretTypeFromPayload -Payload $payload } else { [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString } $secrets += [Microsoft.PowerShell.SecretManagement.SecretInformation]::new( $secretName, $secretType, $VaultName ) } } } } return $secrets } catch { Write-Error "Failed to get secret info: $($_.Exception.Message)" return @() } } function Test-SecretVault { [CmdletBinding()] param( [string] $VaultName, [hashtable] $AdditionalParameters ) try { # Test if secret-tool is available $null = Get-Command secret-tool -ErrorAction Stop return $true } catch { Write-Error "secret-tool command not found. Please install libsecret-tools." return $false } } function Unlock-SecretVault { [CmdletBinding()] param( [SecureString] $Password, [string] $VaultName, [hashtable] $AdditionalParameters ) # Linux keyring unlocks automatically with desktop session # No password required - this is the key benefit of this vault return $true } |