SecretManagement.KeyChain.Extension/SecretManagement.KeyChain.Extension.psm1

$keyChainName = 'SecretManagement.KeyChain'
$securityCmd = '/usr/bin/security'

# Helpers

# SecretManagement converts strings in hashtable to securestring before handing it to extensions
# since I need to serialize to json, we need to convert everything to just strings
function Convert-HashTable([hashtable]$h) {
    $newh = @{}
    foreach ($key in $h.Keys) {
        if ($h[$key] -is [hashtable]) {
            $newh.$key = Convert-HashTable $h[$key]
        }
        elseif ($h[$key] -is [PSCredential]) {
            $newh.$key = @{
                __type = 'PSCredential'
                UserName = $h[$key].UserName
                Password = $h[$key].Password | ConvertFrom-SecureString -AsPlainText
            }
        }
        elseif ($h[$key] -is [SecureString]) {
            $newh.$key = $h[$key] | ConvertFrom-SecureString -AsPlainText
        }
        else {
            $newh.$key = $h[$key]
        }
    }

    $newh
}

function Convert-HashTableCreds([hashtable]$h) {
    $newh = @{}
    foreach ($key in $h.Keys) {
        if ($h[$key].__type -eq 'PSCredential') {
            $newh.$key = [PSCredential]::new($h[$key].UserName, ($h[$key].Password | ConvertTo-SecureString -AsPlainText))
        }
        elseif ($h[$key] -is [hashtable]) {
            $newh.$key = Convert-HashTableCreds $h[$key]
        }
        else {
            $newh.$key = $h[$key]
        }
    }

    $newh
}

function ConvertTo-Base64([string]$text) {
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
    [System.Convert]::ToBase64String($bytes)
}

function ConvertFrom-Base64([string]$base64) {
    $bytes = [System.Convert]::FromBase64String($base64)
    [System.Text.Encoding]::UTF8.GetString($bytes)
}

function Get-SecretType($typename) {
    switch($typename) {
        'STRG' {
            [Microsoft.PowerShell.SecretManagement.SecretType]::String
        }
        'SSTR' {
            [Microsoft.PowerShell.SecretManagement.SecretType]::SecureString
        }
        'BYTE' {
            [Microsoft.PowerShell.SecretManagement.SecretType]::ByteArray
        }
        'CRED' {
            [Microsoft.PowerShell.SecretManagement.SecretType]::PSCredential
        }
        'HASH' {
            [Microsoft.PowerShell.SecretManagement.SecretType]::Hashtable
        }
        default {
            [Microsoft.PowerShell.SecretManagement.SecretType]::Unknown
        }
    }    
}

function Convert-KeyChainSecret([string]$text) {
    # Example output that needs to be parsed:
    #
    # keychain: "/Users/steve/Library/Keychains/SecretManagement.KeyChain-db"
    # version: 512
    # class: "genp"
    # attributes:
    # 0x00000007 <blob>="KeyChain"
    # 0x00000008 <blob>=<NULL>
    # "acct"<blob>="mysecret"
    # "cdat"<timedate>=0x32303230313030393233303831315A00 "20201009230811Z\000"
    # "crtr"<uint32>="STRG"
    # "cusi"<sint32>=<NULL>
    # "desc"<blob>=<NULL>
    # "gena"<blob>=<NULL>
    # "icmt"<blob>=<NULL>
    # "invi"<sint32>=<NULL>
    # "mdat"<timedate>=0x32303230313030393233303831315A00 "20201009230811Z\000"
    # "nega"<sint32>=<NULL>
    # "prot"<blob>=<NULL>
    # "scrp"<sint32>=<NULL>
    # "svce"<blob>="KeyChain"
    # "type"<uint32>=<NULL>
    # password: "secret"

    if ($text -match '"acct"\<blob\>="(.*?)"') {
        $name = $matches[1]
    }

    if ($text -match '"svce"\<blob\>="(.*?)"') {
        $vaultname = $matches[1]
    }

    if ($text -match '"crtr"<uint32>="(.*?)"') {
        $typename = $matches[1]
    }

    if ($text -match 'password: "(.*?)"') {
        $secret = ConvertFrom-Base64 $matches[1]
    }

    if ($null -eq $name -or $null -eq $typename) {
        throw "Failed to parse KeyChain text output: $text"
    }

    [PSCustomObject]@{
        Name = $name
        TypeName = $typename
        Type = (Get-SecretType $typename)
        Secret = $secret
        VaultName = $vaultname
    }
}

# KeyChain is case sensitive, so make sure to use the original casing
function Get-VaultName([string]$name) {
    $vault = Get-SecretVault -Name $name
    $vault.Name
}

# Public functions
function Get-Secret {
    param (
        [string] $Name,
        [string] $VaultName,
        [hashtable] $AdditionalParameters
    )

    $Name = $Name.ToLower()
    $VaultName = Get-VaultName $VaultName

    $out = & $securityCmd find-generic-password -a $Name -s $VaultName -g $keyChainName 2>&1 | Out-String
    if ($out -notmatch 'password: "(.*?)"') {
        throw $out
    }

    $secret = Convert-KeyChainSecret $out
    if ($null -eq $secret.Secret) {
        throw "Not able to parse KeyChain for secret"
    }

    switch ($secret.TypeName) {
        'STRG' {
            $secretObj = $secret.Secret
        }
        'SSTR' {
            $secretObj = ConvertTo-SecureString $secret.Secret -AsPlainText
        }
        'HASH' {
            $secretObj = $secret.Secret | ConvertFrom-Json -AsHashTable
            $secretObj = Convert-HashTableCreds $secretObj
        }
        'BYTE' {
            $secretObj = [byte[]]::new($secret.Secret.Length / 2)

            for ($i = 0; $i -lt $secret.Secret.Length; $i += 2) {
                $secretObj[$i/2] = [System.Convert]::ToByte($secret.Secret.Substring($i, 2), 16)
            }
        }
        'CRED' {
            $cred = $secret.Secret | ConvertFrom-Json
            $secretObj = [PSCredential]::new($cred.UserName, ($cred.Password | ConvertTo-SecureString -AsPlainText))
        }
        default {
            throw "Unknown type: $($secret.TypeName)"
        }
    }

    return $secretObj
}

function Set-Secret {
    param (
        [string] $Name,
        [object] $Secret,
        [string] $VaultName,
        [hashtable] $AdditionalParameters
    )

    $VaultName = Get-VaultName $VaultName
    Test-SecretVault -VaultName $VaultName

    # KeyChain is case-sensitive, so always use lowercase
    $Name = $Name.ToLower()

    switch ($Secret.GetType()) {
        'String' {
            $type = 'STRG'
        }
        'SecureString' {
            $type = 'SSTR'
            $Secret = $Secret | ConvertFrom-SecureString -AsPlainText
        }
        'Hashtable' {
            $type = 'HASH'
            $Secret = Convert-HashTable $secret | ConvertTo-Json -Depth 100 -Compress
        }
        'Byte[]' {
            $type = 'BYTE'
            $Secret = [System.BitConverter]::ToString($Secret).Replace('-','')
        }
        'PSCredential' {
            $type = 'CRED'
            $Secret = [PSCustomObject]@{
                UserName = $Secret.UserName
                Password = $Secret.Password | ConvertFrom-SecureString -AsPlainText
            } | ConvertTo-Json -Compress
        }
        default {
            throw "Unsupported object type: $($Secret.GetType().Name)"
        }
    }

    $Secret = ConvertTo-Base64 $Secret
    $out = & $securityCmd add-generic-password -a $Name -s $VaultName -c $type -w $Secret -U $keyChainName 2>&1
    if (!$?) {
        throw $out
    }

    return $?
}

function Remove-Secret {
    param (
        [string] $Name,
        [string] $VaultName,
        [hashtable] $AdditionalParameters
    )

    $VaultName = Get-VaultName $VaultName

    # KeyChain is case-sensitive, so always use lowercase
    $Name = $Name.ToLower()

    $out = & $securityCmd delete-generic-password -a $Name -s $VaultName $keyChainName 2>&1 | Out-String
    $exitstatus = $out -match 'password has been deleted'
    if (!$exitstatus) {
        throw $out
    }
    return $exitstatus
}

function Get-SecretInfo {
    param (
        [string] $Filter,
        [string] $VaultName,
        [hashtable] $AdditionalParameters
    )

    $VaultName = Get-VaultName $VaultName

    if ($filter.Contains('*')) {
        $output = & $securityCmd dump-keychain $keyChainName | Out-String
        if (!$?) {
            throw "Unable to get contents of KeyChain"
        }

        if ($null -eq $output) {
            return
        }

        foreach ($secretText in $output.Split('keychain:', [System.StringSplitOptions]::RemoveEmptyEntries)) {
            $secret = Convert-KeyChainSecret $secretText

            if ($secret.Name -like $Filter -and $secret.VaultName -eq $VaultName) {
                [Microsoft.PowerShell.SecretManagement.SecretInformation]::new(
                    $secret.Name,
                    $secret.Type,
                    $VaultName
                )
            }
        }
    }
    else {
        $output = & $securityCmd find-generic-password -a $Filter -s $VaultName $keyChainName | Out-String
        if (!$?) {
            return $null
        }

        $secret = Convert-KeyChainSecret $output
        [Microsoft.PowerShell.SecretManagement.SecretInformation]::new(
            $secret.Name,
            $secret.Type,
            $VaultName
        )
    }
}

function Test-SecretVault {
    [CmdletBinding()]
    param (
        [string] $VaultName,
        [hashtable] $AdditionalParameters
    )
    if ($AdditionalParameters.Verbose) {
        $VerbosePreference = 'Continue'
    }
    # VaultName corresponds to service within a secret item
    # SecretManagement.KeyChain is a constant for this extension
    #
    # show-keychain-info - possible outputs
    # Keychain "SecretManagement.KeyChain" no-timeout
    # Keychain "SecretManagement.KeyChain" timeout=240s
    # Keychain "SecretManagement.KeyChain" lock-on-sleep timeout=300s
    # security: SecKeychainCopySettings SecretManagement.KeyChain: The specified keychain could not be found.

    $out = & $securityCmd show-keychain-info $keyChainName 2>&1 | Out-String
    $keyChainExists = $out -match '^Keychain'
    Write-Verbose -Message $out
    Write-Verbose -Message ('keyChainExists {0}' -f $keyChainExists)
    if (!$keyChainExists) {
        & $securityCmd create-keychain -P $keyChainName
        # confirm keychain was properly created
        $out = & $securityCmd show-keychain-info $keyChainName 2>&1 | Out-String
        $keyChainExists = $out -match '^Keychain'
        Write-Verbose -Message $out
        Write-Verbose -Message ('keyChainExists {0}' -f $keyChainExists)    
    }
    return $keyChainExists
}