PSEncrypt.psm1

function New-ErrorRecord {
    <#
    .SYNOPSIS
    Generate a new error record object.
     
    .DESCRIPTION
    Generate a new error record object.
    Used to create custom errors.
     
    .PARAMETER Message
    The error message to include.
     
    .PARAMETER ErrorID
    The error ID to provide to the record
     
    .PARAMETER Category
    What kind of error it was.
     
    .PARAMETER Target
    Any target object to include for better analysis options.
     
    .EXAMPLE
    PS C:\> New-ErrorRecord -Message "Something broke".
     
    Creates an error record with a very helpful error message.
 
    .EXAMPLE
    PS C:\> New-ErrorRecord -Message "Target file not found: $Path" -ErrorID "FileNotFound" -Category ObjectNotFound -Target $Path
 
    Creates an error record with full metadata.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [OutputType([System.Management.Automation.ErrorRecord])]
    [CmdletBinding()]
    param (
        [string]
        $Message,

        [string]
        $ErrorID = '<undefined>',

        [System.Management.Automation.ErrorCategory]
        $Category = [System.Management.Automation.ErrorCategory]::NotSpecified,

        $Target
    )

    $exception = [System.Exception]::new($Message)
    [System.Management.Automation.ErrorRecord]::new($exception, $ErrorID, $Category, $Target)
}

function Resolve-PathEx {
    <#
    .SYNOPSIS
        Resolve a path.
     
    .DESCRIPTION
        Resolve a path.
        Allows specifying success criteria, as well as selecting files or folders only.
     
    .PARAMETER Path
        The input path to resolve.
     
    .PARAMETER Type
        What kind of item to resolve to.
        Supported types:
        - Any: Can be whatever, so long as it exists.
        - File: Must be a file/leaf object and exist
        - Directory: Must be a container/directory object and exist
        - NewFile: The parent path must exist and be a container/directory. The item itself needs not exist, but if it exists, it must be a leaf/file
        Defaults to Any.
     
    .PARAMETER SingleItem
        Whether resolving to more than one item should cause an error.
     
    .PARAMETER Mode
        How results should be handled:
        - Any: At least one single successful path must be resolved, any errors are ignored as long as at least one is valid.
        - All: All items resolved must be valid.
        - AnyWarning: At least one single successful path must be resolved, any errors are filed as warning.
 
    .PARAMETER Provider
        What provider the item must be from.
        Defaults to FileSystem.
     
    .EXAMPLE
        PS C:\> Resolve-PathEx -Path .
         
        Resolves the current path.
 
    .EXAMPLE
        PS C:\> Resolve-PathEx -Path .\test\report.csv -Type NewFile -SingleItem
 
        Must resolve the full path of the file "report.csv" in the folder "test" under the current path.
        The file need not exist, but the folder must be present.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,

        [ValidateSet('File', 'Directory', 'Any', 'NewFile')]
        [string]
        $Type = 'Any',

        [switch]
        $SingleItem,

        [ValidateSet('Any', 'All', 'AnyWarning')]
        [string]
        $Mode = 'Any',

        [string]
        $Provider = 'FileSystem'
    )

    process {
        foreach ($pathEntry in $Path) {
            $data = [PSCustomObject]@{
                Input   = $pathEntry
                Path    = $null
                Success = $false
                Message = ''
                Error   = $null
            }
    
            $basePath = $pathEntry
            if ('NewFile' -eq $Type) {
                $basePath = Split-Path -Path $pathEntry -Parent
                $leaf = Split-Path -Path $pathEntry -Leaf
            }
            try { $resolved = (Resolve-Path -Path $basePath -ErrorAction Stop).ProviderPath }
            catch {
                $data.Error = $_
                $data.Message = "Path cannot be resolved: $pathEntry"
                return $data
            }
    
            if (@($resolved).Count -gt 1 -and $SingleItem) {
                $data.Message = "More than one item found: $pathEntry"
                return $data
            }
    
            $paths = [System.Collections.ArrayList]@()
            $messages = [System.Collections.ArrayList]@()
            $success = $false
            $failed = $false
    
            foreach ($resolvedPath in $resolved) {
                $item = Get-Item -LiteralPath $resolvedPath
    
                if ($Provider -ne $item.PSProvider.Name) {
                    $failed = $true
                    $null = $messages.Add("Not a $Provider path: $($resolvedPath)")
                    continue
                }
    
                if ('File' -eq $Type -and $item.PSIsContainer) {
                    $failed = $true
                    $null = $messages.Add("Not a file: $($resolvedPath)")
                    continue
                }
    
                if ('Directory' -eq $Type -and -not $item.PSIsContainer) {
                    $failed = $true
                    $null = $messages.Add("Not a directory: $($resolvedPath)")
                    continue
                }
    
                if ('NewFile' -eq $Type) {
                    if (-not $item.PSIsContainer) {
                        $failed = $true
                        $null = $messages.Add("Parent of $($pathEntry) is not a container: $($resolvedPath)")
                        continue
                    }
    
                    $newFilePath = Join-Path -Path $resolvedPath -ChildPath $leaf
                    if (Test-Path -LiteralPath $newFilePath -PathType Container) {
                        $failed = $true
                        $null = $messages.Add("Target path $($newFilePath) must not be a directory!")
                        continue
                    }
    
                    $null = $paths.Add($newFilePath)
                    $success = $true
                    continue
                }
    
                $null = $paths.Add($resolvedPath)
                $success = $true
            }
    
            $data.Path = $($paths)
            $data.Message = $($messages)
            foreach ($pathItem in $data.Path) {
                Write-Verbose "Resolved $pathEntry to $pathItem"
            }
    
            switch ($Mode) {
                'Any' {
                    $data.Success = $success
                    foreach ($message in $data.Message) {
                        Write-Verbose $message
                    }
                }
                'All' {
                    $data.Success = -not $failed
                    foreach ($message in $data.Message) {
                        Write-Verbose $message
                    }
                }
                'AnyWarning' {
                    $data.Success = $success
                    foreach ($message in $data.Message) {
                        Write-Warning $message
                    }
                }
            }
    
            $data
        }
    }
}

function Show-SaveFileDialog {
    <#
    .SYNOPSIS
        Shows a visual dialog, prompting the user to pick a path where to write a file to.
     
    .DESCRIPTION
        Shows a visual dialog, prompting the user to pick a path where to write a file to.
     
    .PARAMETER InitialDirectory
        Initial folder from which the user may navigate to wherever.
     
    .PARAMETER Filter
        Filter string to constrain user option on what filetype to save as.
        E.g.: "Json Files (*.json)|*.json"
     
    .PARAMETER Filename
        Default filename, which is offered to the user.
     
    .EXAMPLE
        PS C:\> Show-SaveFileDialog
 
        Opens a "Save file" dialog in the current path.
 
    .EXAMPLE
        PS C:\> Show-SaveFileDialog -InitialDirectory $HOME -Filter 'CSV Files (*.csv)|*.csv' -FileName report.csv
 
        Opens a "Save file" dialog in the user profile, filtering for CSV files with "report.csv" as the default filename.
    #>

    [CmdletBinding()]
    param (
        [string]
        $InitialDirectory = '.',

        [string]
        $Filter = '*.*',
        
        $Filename
    )

    Add-Type -AssemblyName System.Windows.Forms -ErrorAction Ignore
    
    $saveFileDialog = [Windows.Forms.SaveFileDialog]::new()
    $saveFileDialog.FileName = $Filename
    $saveFileDialog.InitialDirectory = Resolve-Path -Path $InitialDirectory
    $saveFileDialog.Title = "Save File to Disk"
    $saveFileDialog.Filter = $Filter
    $saveFileDialog.ShowHelp = $True
    
    $result = $saveFileDialog.ShowDialog()
    if ($result -eq "OK") {
        $saveFileDialog.FileName
    }
}

function Export-PseCertificate {
    <#
    .SYNOPSIS
        Creates an export of your own PSEncrypt certificate (public key only).
     
    .DESCRIPTION
        Creates an export of your own PSEncrypt certificate (public key only).
        This is used by other users of PSEncrypt to encrypt data to send to you.
     
    .PARAMETER Path
        Path to the json-file to create, containing the public information for your certificate.
     
    .PARAMETER PassThru
        Rather than generating a file, return your certificate data as json string.
        This makes it easy to share it via text-messengers such as teams or discord.
     
    .EXAMPLE
        PS C:\> Export-PseCertificate -Path .\psencrypt-certificate.json
         
        Exports you own PSEncrypt certificate to .\psencrypt-certificate.json
        Provide this to a recipient you intend to exchange data with securely.
 
    .EXAMPLE
        PS C:\> Export-PseCertificate -PassThru | Set-Clipboard
         
        Exports you own PSEncrypt certificate as json string and writes it to your clipboard.
        Provide this to a recipient you intend to exchange data with securely.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Path,

        [switch]
        $PassThru
    )

    begin {
        if (-not $Path -and -not $PassThru) {
            $Path = Show-SaveFileDialog -Filter 'Json Files (*.json)|*.json'
            if (-not $Path) {
                throw "no export path found! Specify Path or use the UI prompt to specify an export path!"
            }
        }
    }
    process {
        $cert = Get-PseCertificate -Current | ForEach-Object Certificate
        if (-not $cert) {
            throw "No PSEncrypt certificate found, use New-PseCertificate to create one!"
        }

        $certBytes = $cert.GetRawCertData()

        $data = @{
            Name = $cert.Subject -replace '^CN=|, O=PSEncrypt$'
            Cert = [Convert]::ToBase64String($certBytes)
        }
        if ($PassThru) { return $data | ConvertTo-Json }
        $data | ConvertTo-Json | Set-Content -Path $Path
    }
}

function Get-PseCertificate {
<#
.SYNOPSIS
    Retrieves PSEncrypt certificates.
 
.DESCRIPTION
    Retrieves certificates used for PSEncrypt.
    These are used to decrypt content intended for you.
 
.PARAMETER Current
    Specifies to return only the most current certificate.
 
.EXAMPLE
    PS C:\> Get-PseCertificate
 
    List all certificates created for PSEncrypt
 
.EXAMPLE
    PS C:\> Get-PseCertificate -Current
 
    Retrieves the most current certificate generated for PSEncrypt.
#>

    [CmdletBinding()]
    param (
        [switch]
        $Current
    )

    process {
        $certificates = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object FriendlyName -EQ 'PSEncrypt Certificate' | ForEach-Object {
            [PSCustomObject]@{
                PSTypeName  = 'PSEncrypt.Certificate'
                Subject     = $_.Subject
                NotAfter    = $_.NotAfter
                Thumbprint  = $_.Thumbprint
                Certificate = $_
            }
        }
        if (-not $Current) { return $certificates }

        $certificates | Sort-Object NotAfter -Descending | Select-Object -First 1
    }
}

function Get-PseContact {
    <#
    .SYNOPSIS
        Get a list of all contacts you have registered.
     
    .DESCRIPTION
        Get a list of all contacts you have registered.
        Contacts are acquaintances that have shared teir certificate with you, enabling you to exchange secure files & data with them.
     
    .PARAMETER Name
        The name of the contact to filter by.
        Defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-PseContact
 
        List all PSEncrypt contacts.
 
    .EXAMPLE
        PS C:\> Get-PSseContact -Name fred@infernal-associates.org
 
        Return the PSEncrypt contact with the name fred@infernal-associates.org
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process {
        Get-ChildItem -Path $script:certFolder | Where-Object Name -Match '^[0-9A-F]{40}\.clixml$' | Import-Clixml | Where-Object Name -Like $Name | ForEach-Object {
            $_.PSObject.TypeNames.Insert(0, 'PSEncrypt.Contact')
            $_
        }
    }
}

function Import-PseContact {
    <#
    .SYNOPSIS
    Imports contact information needed to send encrypted data to the creator of that information.
     
    .DESCRIPTION
    Imports contact information needed to send encrypted data to the creator of that information.
    Reads the clipboard if no other data is provided.
     
    .PARAMETER Path
    Path to the json file containing the contact information.
     
    .PARAMETER Content
    The json string containing the contact information.
     
    .PARAMETER TrustedOnly
    Only accept contact certificates from a trusted root authority.
    By default, any self-signed certificate will do.
     
    .EXAMPLE
    PS C:\> Import-PseContact
     
    Import the json contact data from the clipboard and register it as a new contact.
 
    .EXAMPLE
    PS C:\> Get-ChildItem .\contacts\*.json | Import-PseContact -TrustedOnly
 
    Import all the contacs files in the "contacts" subfolder, ensuring only contacts with trusted certificates are imported.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,

        [string]
        $Content,

        [switch]
        $TrustedOnly
    )
    
    begin {
        function Import-ContactData {
            [CmdletBinding()]
            param (
                [string]
                $Data,

                $Cmdlet,

                [switch]
                $TrustedOnly
            )

            try { $jsonContent = $Data | ConvertFrom-Json -ErrorAction Stop }
            catch {
                $Cmdlet.WriteError($_)
                return
            }

            if (-not $jsonContent.Name -or -not $jsonContent.Cert) {
                $record = New-ErrorRecord -Message 'Invalid json structure - ensure the data provided has been generated through Export-PseCertificate!' -ErrorID InvalidData -Category InvalidData
                $Cmdlet.WriteError($record)
                return
            }

            try {
                $bytes = [Convert]::FromBase64String($jsonContent.Cert)
                $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)
            }
            catch {
                $record = New-ErrorRecord -Message "Invalid certificate data for $($jsonContent.Name) - ensure the data provided has been generated through Export-PseCertificate!" -ErrorID InvalidData -Category InvalidData
                $Cmdlet.WriteError($record)
                return
            }

            if ($TrustedOnly -and -not $certificate.Verify()) {
                $record = New-ErrorRecord -Message "Invalid certificate for $($jsonContent.Name) - the certificate $($certificate.Subject) ($($certificate.ThumbPrint)) is not trusted!" -ErrorID NotTrusted -Category SecurityError
                $Cmdlet.WriteError($record)
                return
            }

            $certData = [PSCustomObject]@{
                PSTypeName  = 'PSEncrypt.Contact'
                Name        = $Name
                Thumbprint  = $certificate.Thumbprint
                NotAfter    = $certificate.NotAfter
                Certificate = $certificate
            }

            $exportPath1 = Join-Path -Path $script:certFolder -ChildPath "$($certData.Name).clixml"
            $exportPath2 = Join-Path -Path $script:certFolder -ChildPath "$($certData.Thumbprint).clixml"
            $certData | Export-Clixml -Path $exportPath1
            $certData | Export-Clixml -Path $exportPath2
            $certData
        }
    }
    process {
        if (-not $Content -and -not $Path) {
            $Content = (Get-Clipboard) -join "`n"
        }
        if ($Content) {
            Import-ContactData -Data $Content -Cmdlet $PSCmdlet -TrustedOnly:$TrustedOnly
        }

        if (-not $Path) { return }
        
        foreach ($file in Resolve-PathEx -Path $Path -Type File -Mode AnyWarning -Provider FileSystem) {
            foreach ($filePath in $file.Path) {
                Write-Verbose "Importing: $filePath"
                $text = [System.IO.File]::ReadAllText($filePath)
                Import-ContactData -Data $text -Cmdlet $PSCmdlet -TrustedOnly:$TrustedOnly
            }
        }
    }
}

function New-PseCertificate {
    <#
    .SYNOPSIS
    Generate a new certificate to use as your own PSEncrypt certificate, enabling you to receive encrypted data.
     
    .DESCRIPTION
    Generate a new certificate to use as your own PSEncrypt certificate, enabling you to receive encrypted data.
    This generates a self-signed certificate, useful for quickly enabling use of PSEncrypt.
 
    If you want to also ensure trusted certificates, instead of using this command, issue a certificate with the friendly name "PSEncrypt Certificate",
    usable for document signing and document encryption (DigitalSignature, DataEncipherment)
     
    .PARAMETER Name
    Name of the certificate to assign.
    By default, it will attempt to read your username from MS Teams if present.
     
    .EXAMPLE
    PS C:\> New-PseCertificate
     
    Creates a new certificate to use as your own PSEncrypt certificate, using your Teams account name as name
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string]
        $Name
    )

    begin {
        if (-not $Name) {
            $Name = (Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Office\Teams' -ErrorAction Ignore).HomeUserUpn
        }
        if (-not $Name) {
            throw "Name not resolveable, manually specify it through the -Name parameter!"
        }
    }
    process {
        $cert = New-SelfSignedCertificate -KeyUsage DigitalSignature, DataEncipherment -Subject "CN=$Name, O=PSEncrypt" -CertStoreLocation Cert:\CurrentUser\My -NotAfter (Get-Date).AddYears(20) -FriendlyName 'PSEncrypt Certificate'
        Get-PseCertificate | Where-Object Thumbprint -EQ $cert.Thumbprint
    }
}

function Protect-PseDocument {
    <#
    .SYNOPSIS
    Encrypt a document for a specific recipient and sign it with your own certificate.
     
    .DESCRIPTION
    Encrypt a document for a specific recipient and sign it with your own certificate.
 
    Can protect both files and string content.
 
    Files:
    - When only providing a path, it will create a new file, appending ".json" in the same path.
    - When also providing an "OutPath", it will create a new file, appending ".json", and write it to that path.
    - When specifying -PassThru, the resultant json string will be returned as output.
 
    Content:
    - Will be returned as protected json string.
     
    .PARAMETER Recipient
    The recipient to encrypt the data for.
    Use Import-PseContact to add a valid recipient, have your contact use Export-PseCertificate to generate the data consumed by Import-PseContact.
     
    .PARAMETER Path
    Path to the file to protect.
     
    .PARAMETER Content
    String content to protect-
     
    .PARAMETER Name
    Name to assign to the Content.
    By default, a GUID and date are used.
     
    .PARAMETER PassThru
    Whether to return the protected json data as string, rather than writing files.
     
    .PARAMETER OutPath
    Path in which to write protected files.
    Must be a folder that exists.
    By default, protected files are stored in the same path as the original input file.
     
    .EXAMPLE
    PS C:\> Protect-PseDocument -Recipient fred@contoso.com -Path .\security-roadmap.pptx
     
    Creates a protected file from "security-roadmap.pptx" named "security-roadmap.pptx.json" in the same path as the original file.
    Only the intended recipient "fred@contoso.com" should have the certificate to decrypt this document.
    The recipient can also verify, that the file was originally protected by the current user.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    [CmdletBinding(DefaultParameterSetName = 'File')]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Recipient,

        [Parameter(Mandatory = $true, ParameterSetName = 'File', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'Content', ValueFromPipelineByPropertyName = $true)]
        [string]
        $Content,

        [Parameter(ParameterSetName = 'Content', ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name = "$([guid]::NewGuid())-$(Get-Date -Format yyyy-MM-dd)",

        [Parameter(ParameterSetName = 'File')]
        [switch]
        $PassThru,

        [Parameter(ParameterSetName = 'File')]
        [string]
        $OutPath
    )
    
    begin {
        #region Functions
        function Protect-Content {
            [CmdletBinding()]
            param (
                [string]
                $Content,

                [string]
                $Name,

                [System.Security.Cryptography.X509Certificates.X509Certificate2]
                $OwnCertificate,

                $Contact
            )

            $bytes = [System.Text.Encoding]::UTF8.GetBytes($Content)
            $bytesEncrypted = $Contact.Certificate.PublicKey.GetRSAPublicKey().Encrypt($bytes, [System.Security.Cryptography.RSAEncryptionPadding]::Pkcs1)
            $bytesSignature = $OwnCertificate.PrivateKey.SIgnData($bytesEncrypted, [System.Security.Cryptography.HashAlgorithmName]::SHA512, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)

            @{
                Name            = $Name
                Recipient       = $Contact.Name
                Type            = 'Content'
                SignThumbprint  = $OwnCertificate.Thumbprint
                CryptThumbprint = $Contact.Certificate.Thumbprint
                Data            = [convert]::ToBase64String($bytesEncrypted)
                Signature       = [convert]::ToBase64String($bytesSignature)
            } | ConvertTo-Json
        }

        function Protect-File {
            [CmdletBinding()]
            param (
                [string]
                $Path,

                [System.Security.Cryptography.X509Certificates.X509Certificate2]
                $OwnCertificate,

                $Contact,

                [switch]
                $PassThru,

                [AllowEmptyString()]
                [string]
                $OutPath
            )

            $bytes = [System.IO.File]::ReadAllBytes($Path)
            $bytesEncrypted = $Contact.Certificate.PublicKey.GetRSAPublicKey().Encrypt($bytes, [System.Security.Cryptography.RSAEncryptionPadding]::Pkcs1)
            $bytesSignature = $OwnCertificate.PrivateKey.SignData($bytesEncrypted, [System.Security.Cryptography.HashAlgorithmName]::SHA512, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)

            $fileName = Split-Path -Path $Path -Leaf
            $data = @{
                Name            = $fileName
                Recipient       = $Contact.Name
                Type            = 'File'
                SignThumbprint  = $OwnCertificate.Thumbprint
                CryptThumbprint = $Contact.Certificate.Thumbprint
                Data            = [convert]::ToBase64String($bytesEncrypted)
                Signature       = [convert]::ToBase64String($bytesSignature)
            } | ConvertTo-Json

            if ($PassThru) { $data }
            if ($OutPath) {
                $newPath = Join-Path -Path $OutPath -ChildPath "$($fileName).json"
                $data | Set-Content -Path $newPath
                Write-Host "Protected file created at: $newPath"
            }
            if (-not $PassThru -and -not $OutPath) {
                $data | Set-Content -Path "$Path.json"
                Write-Host "Protected file created at: $Path.json"
            }
        }
        #endregion Functions

        $ownCertificate = Get-PseCertificate -Current | ForEach-Object Certificate
        if (-not $ownCertificate) {
            $record = New-ErrorRecord -Message 'No applicable user certificate found! Use New-PseCertificate to register a certificate to use.' -ErrorID 'CertNotFound' -Category ObjectNotFound
            $PSCmdlet.ThrowTerminatingError($record)
        }

        $contact = Get-PseContact | Where-Object {
            $_.Name -eq $Recipient -or
            $_.Thumbprint -eq $Recipient
        } | Sort-Object NotAfter -Descending | Select-Object -First 1

        if (-not $contact) {
            $record = New-ErrorRecord -Message "Contact $Recipient not found! Check your spelling against Get-PseContact or use Import-PseContact to import a new contact. Your contact can generate the import data using Export-PseCertificate." -ErrorID 'ContactNotFound' -Category ObjectNotFound
            $PSCmdlet.ThrowTerminatingError($record)
        }
    }
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'File' {
                foreach ($file in Resolve-PathEx -Path $Path -Type File -Mode AnyWarning -Provider FileSystem | ForEach-Object Path) {
                    Protect-File -Path $file -OwnCertificate $ownCertificate -Contact $contact -PassThru:$PassThru -OutPath $OutPath
                }
            }
            'Content' {
                Protect-Content -Content $Content -Name $Name -OwnCertificate $ownCertificate -Contact $contact
            }
        }
    }
}


function Remove-PseContact
{
    <#
    .SYNOPSIS
    Remove a contact from the list of known PSEncrypt contacts.
     
    .DESCRIPTION
    Remove a contact from the list of known PSEncrypt contacts.
    This deletes the public certificates needed to send new protected files or verify protected data sent by the contact deleted.
     
    .PARAMETER Name
    Name of the contact to remove
     
    .EXAMPLE
    PS C:\> Remove-PseContact -Name fred@contoso.com
 
    Removes the PSEncrypt contact "fred@contoso.com"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($entry in $Name) {
            $contact = $null
            $contact = Get-PseContact -Name $entry
            if (-not $contact) { continue }

            $badCharacters = [System.IO.Path]::GetInvalidFileNameChars() -replace '\\', '\\' -replace '\|','\|' -join '|'
            $exportPath1 = Join-Path -Path $script:certFolder -ChildPath "$($contact.Name -replace $badCharacters,'_').clixml"
            $exportPath2 = Join-Path -Path $script:certFolder -ChildPath "$($contact.Thumbprint).clixml"

            if (Test-Path $exportPath1) { Remove-Item -Path $exportPath1 }
            if (Test-Path $exportPath2) { Remove-Item -Path $exportPath2 }
        }
    }
}


function Unprotect-PseDocument {
    <#
    .SYNOPSIS
        Decrypts data or file encrypted with PSEncrypt for the current user.
     
    .DESCRIPTION
        Decrypts data or file encrypted with PSEncrypt for the current user.
        The encrypted data must have been generated using Protect-PseDocument with the current user as its recipient.
 
        The data will be verified with the signature included in the data against the certificate of the contact representing the sender.
        Data from untrusted sources - senders that are not in the list of contacts of the current user - will be rejected.
 
        To include the sender as a trusted sender, have the sender use Export-PseCertificate to provide the contact information,
        and use Import-PseContact to use that contact information and add the sender to your contacts.
     
    .PARAMETER Path
        Path to the file to decrypt.
        Decrypted file will be created in the same path, unless OutPath is specified.
     
    .PARAMETER Content
        The json string containing data encrypted by PSEncrypt.
        If the original content was a file, specfying an OutPath becomes mandatory.
     
    .PARAMETER OutPath
        Path in which to generate the decrypted document.
        By default, encrypted files will be written in the same path as the original file and encrypted string content will be returned as decrypted string.
     
    .EXAMPLE
        PS C:\> Unprotect-PseDocument -Path .\report.xlsx.json
 
        Decrypts "report.xlsx.json" in the same path under the original filename (probably "report.xlsx")
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName = 'File', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'Content', ValueFromPipelineByPropertyName = $true)]
        [string]
        $Content,

        [string]
        $OutPath
    )
    
    begin {
        function Unprotect-Dataset {
            [CmdletBinding()]
            param (
                [string]
                $Content,

                [AllowEmptyString()]
                [string]
                $OutPath,

                $Cmdlet
            )

            try { $config = $Content | ConvertFrom-Json -ErrorAction Stop }
            catch {
                $Cmdlet.WriteError($_)
                return
            }

            if ($config.Type -eq 'File' -and -not $OutPath) {
                $record = New-ErrorRecord -Message "Invalid configuration: File provided without an OutPath: $($config.Name)" -ErrorID 'BadParameters' -Category InvalidArgument -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            $recipientCert = Get-Item "Cert:\CurrentUser\My\$($config.CryptThumbprint)" -ErrorAction Ignore
            if (-not $recipientCert) {
                $record = New-ErrorRecord -Message "Cannot find certificate $($config.CryptThumbprint) to decrypt data: $($config.Name)" -ErrorID 'CertNotFound' -Category InvalidArgument -Target $config
                $Cmdlet.WriteError($record)
                return
            }
            
            $senderCert = Get-PseContact | Where-Object ThumbPrint -EQ $config.SignThumbprint | ForEach-Object Certificate
            if (-not $senderCert) {
                $record = New-ErrorRecord -Message "Cannot find certificate $($config.SignThumbprint) to verify the sender: $($config.Name)" -ErrorID 'CertNotFound' -Category InvalidArgument -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            if (-not $config.Data -or -not $config.Signature) {
                $record = New-ErrorRecord -Message "Missing Data to decrypt or signature to verify: $($config.Name)" -ErrorID 'BadConfig' -Category InvalidArgument -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            try {
                $bytesData = [convert]::FromBase64String($config.Data)
                $bytesSignature = [convert]::FromBase64String($config.Signature)
            }
            catch {
                $record = New-ErrorRecord -Message "Invalid format for data or signature: $($config.Name)" -ErrorID 'BadConfig' -Category InvalidArgument -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            $isFromSender = $senderCert.PublicKey.GetRSAPublicKey().VerifyData($bytesData, $bytesSignature, [System.Security.Cryptography.HashAlgorithmName]::SHA512, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
            if (-not $isFromSender) {
                $record = New-ErrorRecord -Message "Invalid signature! $($config.Name) could not be verified to come from $($senderCert.Subject) ($($senderCert.Thumbprint))!" -ErrorID 'InvalidSignature' -Category InvalidData -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            try { $decryptedBytes = $recipientCert.PrivateKey.Decrypt($bytesData, [System.Security.Cryptography.RSAEncryptionPadding]::Pkcs1) }
            catch {
                $record = New-ErrorRecord -Message "Error decrypting data! $($config.Name) could not be decrypted wth $($recipientCert.Subject) ($($recipientCert.Thumbprint)): $_" -ErrorID 'InvalidCert' -Category InvalidData -Target $config
                $Cmdlet.WriteError($record)
                return
            }

            if ($config.Type -eq 'Content') {
                $content = [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
                if (-not $OutPath) { return $content }

                $exportPath = Join-Path -Path $OutPath -ChildPath $config.Name
                $content | Set-Content -Path $exportPath -Encoding UTF8
                Write-Host "Unporotected file written to: $exportPath"
            }
            else {
                $exportPath = Join-Path -Path $OutPath -ChildPath $config.Name
                [System.IO.File]::WriteAllBytes($exportPath, $decryptedBytes)
                Write-Host "Unporotected file written to: $exportPath"
            }
        }
    }
    process {
        if ($Content) {
            Unprotect-Dataset -Content $Content -OutPath $OutPath -Cmdlet $PSCmdlet
        }
        foreach ($file in Resolve-PathEx -Path $Path -Type File -Mode AnyWarning -Provider FileSystem | ForEach-Object Path) {
            $root = Split-Path $file
            if ($OutPath) { $root = $OutPath }
            Write-Verbose "Unprotecting: $file to $root"
            Unprotect-Dataset -Content ([System.IO.File]::ReadAllText($file)) -OutPath $root -Cmdlet $PSCmdlet
        }
    }
}


# Folder where config items are persisted. Notably destination certificates.
$script:configFolder = Join-Path $env:APPDATA "PowerShell\PSEncrypt"
$script:certFolder = Join-Path $script:configFolder 'certs'

if (-not (Test-Path $script:certFolder)) {
    $null = New-Item -Path $script:certFolder -ItemType Directory -Force
}


$contactsCompletion = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $contacts = Get-PseContact | ? { ($_.Name -replace "'|`"") -like "$wordToComplete*"}
    foreach ($contact in $contacts) {
        $completion = $contact.Name
        if ($completion -match '\s') { $completion = "'$completion'" }
        [System.Management.Automation.CompletionResult]::new($completion)
    }
}
Register-ArgumentCompleter -CommandName Get-PseContact -ParameterName Name -ScriptBlock $contactsCompletion
Register-ArgumentCompleter -CommandName Protect-PseDocument -ParameterName Recipient -ScriptBlock $contactsCompletion
Register-ArgumentCompleter -CommandName Remove-PseContact -ParameterName Name -ScriptBlock $contactsCompletion