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)] [AllowEmptyCollection()] [AllowEmptyString()] [AllowNull()] [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 { if (-not $Path) { return } 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 { $filter = { if ($script:config.CertThumbprint -and $_.ThumbPrint -eq $script:config.CertThumbprint) { return $true } if ($script:config.CertSubject -and $_.Subject -eq $script:config.CertSubject) { return $true } if ($script:config.CertFriendlyName -and $_.FriendlyName -eq $script:config.CertFriendlyName) { return $true } } $certificates = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object $filter | 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 = $jsonContent.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 Set-PseCertificate { <# .SYNOPSIS Configure the certificate to use for PSEncrypt. .DESCRIPTION Configure the certificate to use for PSEncrypt. The certificate in question must support document signing and document encryption, otherwise it will fail. This command includes no validation! For a simple self-start with a self-signed certificate use New-PseCertificate instead. Documents encrypted for previous certificates will still be decryptable, so long as the old certificate is still available. .PARAMETER Thumbprint Thumbprint of the certificate to use. .PARAMETER FriendlyName Friendly name of the certificate to use. Will always select the certificate with the latest expiration date. .PARAMETER Subject Select a certificate by its subject name. Will always select the certificate with the latest expiration date. .EXAMPLE PS C:\> Set-PseCertificate -Thumbprint $cert.Thumbprint Registers the certificate stored in $cert as certificate to use. .EXAMPLE PS C:\> Set-PseCertificate -Subject 'CN=fred@contoso.com' Registers the latest certificate with the subject "CN=fred@contoso.com" as certificate to use to encrypt. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')] [string] $Thumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'FriendlyName')] [string] $FriendlyName, [Parameter(Mandatory = $true, ParameterSetName = 'Subject')] [string] $Subject ) process { $configuration = @{ CertThumbprint = '' CertFriendlyName = '' CertSubject = '' } if ($Thumbprint) { $configuration.CertThumbprint = $Thumbprint } if ($FriendlyName) { $configuration.CertFriendlyName = $FriendlyName } if ($Subject) { $configuration.CertSubject = $Subject } $script:config = $configuration $configPath = Join-Path -Path $script:configFolder -ChildPath 'config.clixml' $script:config | Export-Clixml -Path $configPath } } 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' $script:config = @{ CertThumbprint = '' CertFriendlyName = 'PSEncrypt Certificate' CertSubject = '' } if (-not (Test-Path $script:certFolder)) { $null = New-Item -Path $script:certFolder -ItemType Directory -Force } $configPath = Join-Path -Path $script:configFolder -ChildPath 'config.clixml' if (Test-Path -Path $configPath) { $script:config = Import-Clixml -Path $configPath } $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 |