Skip to content

Instantly share code, notes, and snippets.

@rmbolger
Last active January 10, 2022 17:56
Show Gist options
  • Save rmbolger/d3f5ec63113e75a4097cca4e579976f0 to your computer and use it in GitHub Desktop.
Save rmbolger/d3f5ec63113e75a4097cca4e579976f0 to your computer and use it in GitHub Desktop.
Convert-PfxToPem.ps1
# Adapted from Vadims Podāns' amazing work here.
# https://www.sysadmins.lv/blog-en/how-to-convert-pkcs12pfx-to-pem-format.aspx
#
# Also, if you need a more complete PKI PowerShell module, he has authored one here:
# https://github.com/Crypt32/PSPKI
#
# This version of the function includes a few fixes from the module's version of the
# function and changes up the output options so you get separate .crt/.key files by
# default named the same as the pfx file (or thumbprint if directly referencing a cert).
# -IncludeChain adds an additional -chain.pem. Relative paths are now
# supported as well.
function Convert-PfxToPem {
[CmdletBinding(DefaultParameterSetName = '__pfxfile')]
param(
[Parameter(Mandatory = $true, ParameterSetName = '__pfxfile', Position = 0)]
[IO.FileInfo]$InputFile,
[Parameter(Mandatory = $true, ParameterSetName = '__cert', Position = 0)]
[Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
[Parameter(Mandatory = $true, ParameterSetName = '__pfxfile', Position = 1)]
[Security.SecureString]$Password,
[Parameter(Position = 2)]
[string]$OutFolder = '.\',
[Parameter(Position = 3)]
[string]$OutName,
[Parameter(Position = 4)]
[ValidateSet("Pkcs1","Pkcs8")]
[string]$OutType = "Pkcs8",
[Alias("chain")]
[switch]$IncludeChain
)
$signature = @"
[DllImport("crypt32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern bool CryptAcquireCertificatePrivateKey(
IntPtr pCert,
uint dwFlags,
IntPtr pvReserved,
ref IntPtr phCryptProv,
ref uint pdwKeySpec,
ref bool pfCallerFreeProv
);
[DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern bool CryptGetUserKey(
IntPtr hProv,
uint dwKeySpec,
ref IntPtr phUserKey
);
[DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern bool CryptExportKey(
IntPtr hKey,
IntPtr hExpKey,
uint dwBlobType,
uint dwFlags,
byte[] pbData,
ref uint pdwDataLen
);
[DllImport("advapi32.dll", CharSet=CharSet.Auto, SetLastError=true)]
public static extern bool CryptDestroyKey(
IntPtr hKey
);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool PFXIsPFXBlob(
CRYPTOAPI_BLOB pPFX
);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool PFXVerifyPassword(
CRYPTOAPI_BLOB pPFX,
[MarshalAs(UnmanagedType.LPWStr)]
string szPassword,
int dwFlags
);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct CRYPTOAPI_BLOB {
public int cbData;
public IntPtr pbData;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct PUBKEYBLOBHEADERS {
public byte bType;
public byte bVersion;
public short reserved;
public uint aiKeyAlg;
public uint magic;
public uint bitlen;
public uint pubexp;
}
"@
Add-Type -MemberDefinition $signature -Namespace PKI -Name PfxTools
function Encode-ASN ([Byte[]]$RawData, [byte]$Tag) {
if ($RawData.Length -lt 128) {
$Tag, $RawData.Length + $RawData
} else {
$hexlength = "{0:x2}" -f $RawData.Length
if ($hexlength.Length % 2) {$hexlength = "0" + $hexlength}
$lengtbytes = @($hexlength -split "([a-f0-9]{2})" | Where-Object {$_} | ForEach-Object {[Convert]::ToByte($_,16)})
$padding = $lengtbytes.Length + 128
$Tag, $padding + $lengtbytes + $RawData
}
}
function Encode-Integer ([Byte[]]$RawData) {
# since CryptoAPI is little-endian by nature, we have to change byte ordering
# to big-endian.
[array]::Reverse($RawData)
# if high byte contains more than 7 bits, an extra zero byte is added
if ($RawData[0] -ge 128) {$RawData = ,0 + $RawData}
Encode-ASN $RawData 2
}
switch ($PsCmdlet.ParameterSetName) {
"__pfxfile" {
$pfxFile = Get-ChildItem (Resolve-Path $InputFile.Name)
$bytes = [IO.File]::ReadAllBytes($pfxFile)
$ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
[Runtime.InteropServices.Marshal]::Copy($bytes,0,$ptr,$bytes.Length)
$pfx = New-Object PKI.PfxTools+CRYPTOAPI_BLOB -Property @{
cbData = $bytes.Length;
pbData = $ptr
}
# just check whether input file is valid PKCS#12/PFX file.
if ([PKI.PfxTools]::PFXIsPFXBlob($pfx)) {
$certs = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection
try {
$certs.Import(
$bytes,
[Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)),
"Exportable"
)
$Certificate = ($certs | Where-Object {$_.HasPrivateKey})[0]
} catch {
throw $_
return
} finally {
[Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
Remove-Variable bytes, ptr, pfx -Force
}
} else {
[Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
Remove-Variable bytes, ptr, pfx -Force
Write-Error -Category InvalidData -Message "Input file is not valid PKCS#12/PFX file." -ErrorAction Stop
}
}
"__cert" {
if (!$Certificate.HasPrivateKey) {
Write-Error -Category InvalidOperation -Message "Specified certificate object does not contain associated private key." -ErrorAction Stop
}
}
}
$CRYPT_ACQUIRE_SILENT_FLAG = 0x40
$PRIVATEKEYBLOB = 0x7
$CRYPT_OAEP = 0x40
$phCryptProv = [IntPtr]::Zero
$pdwKeySpec = 0
$pfCallerFreeProv = $false
# attempt to acquire private key container
if (![PKI.PfxTools]::CryptAcquireCertificatePrivateKey($Certificate.Handle,$CRYPT_ACQUIRE_SILENT_FLAG,0,[ref]$phCryptProv,[ref]$pdwKeySpec,[ref]$pfCallerFreeProv)) {
throw New-Object ComponentModel.Win32Exception ([Runtime.InteropServices.Marshal]::GetLastWin32Error())
return
}
$phUserKey = [IntPtr]::Zero
# attempt to acquire private key handle
if (![PKI.PfxTools]::CryptGetUserKey($phCryptProv,$pdwKeySpec,[ref]$phUserKey)) {
throw New-Object ComponentModel.Win32Exception ([Runtime.InteropServices.Marshal]::GetLastWin32Error())
return
}
$pdwDataLen = 0
# attempt to export private key. This method fails if certificate has non-exportable private key.
if (![PKI.PfxTools]::CryptExportKey($phUserKey,0,$PRIVATEKEYBLOB,$CRYPT_OAEP,$null,[ref]$pdwDataLen)) {
throw New-Object ComponentModel.Win32Exception ([Runtime.InteropServices.Marshal]::GetLastWin32Error())
return
}
$pbytes = New-Object byte[] -ArgumentList $pdwDataLen
[void][PKI.PfxTools]::CryptExportKey($phUserKey,0,$PRIVATEKEYBLOB,$CRYPT_OAEP,$pbytes,[ref]$pdwDataLen)
# release private key handle
[void][PKI.PfxTools]::CryptDestroyKey($phUserKey)
# extracting private key blob header.
$headerblob = $pbytes[0..19]
# extracting actual private key data exluding header.
$keyblob = $pbytes[20..($pbytes.Length - 1)]
Remove-Variable pbytes -Force
# public key structure header has fixed length: 20 bytes: http://msdn.microsoft.com/en-us/library/aa387689(VS.85).aspx
# copy header information to unmanaged memory and copy it to structure.
$ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal(20)
[Runtime.InteropServices.Marshal]::Copy($headerblob,0,$ptr,20)
$header = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr,[Type][PKI.PfxTools+PUBKEYBLOBHEADERS])
[Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
# extract public exponent from blob header and convert it to a byte array
$pubExponentHex = "{0:x2}" -f $header.pubexp
if ($pubExponentHex.Length % 2) {$pubExponentHex = "0" + $pubExponentHex}
$publicExponent = $pubExponentHex -split "([a-f0-9]{2})" | Where-Object {$_} | ForEach-Object {[Convert]::ToByte($_,16)}
# this object is created to reduce code size. This object has properties, where each property represents
# a part (component) of the private key and property value contains private key component length.
# 8 means that the length of the component is KeyLength / 8. Resulting length is measured in bytes.
# for details see private key structure description: http://msdn.microsoft.com/en-us/library/aa387689(VS.85).aspx
$obj = New-Object psobject -Property @{
modulus = 8; privateExponent = 8;
prime1 = 16; prime2 = 16; exponent1 = 16; exponent2 = 16; coefficient = 16;
}
$offset = 0
# I pass variable names (each name represents the component of the private key) to foreach loop
# in the order as they follow in the private key structure and parse private key for
# appropriate offsets and write component information to variable.
"modulus","prime1","prime2","exponent1","exponent2","coefficient","privateExponent" | ForEach-Object {
Set-Variable -Name $_ -Value ($keyblob[$offset..($offset + $header.bitlen / $obj.$_ - 1)])
$offset = $offset + $header.bitlen / $obj.$_
}
# PKCS#1/PKCS#8 uses slightly different component order, therefore I reorder private key
# components and pass them to a simplified ASN encoder.
$asnblob = Encode-Integer 0
$asnblob += "modulus","publicExponent","privateExponent","prime1","prime2","exponent1","exponent2","coefficient" | ForEach-Object {
Encode-Integer (Get-Variable -Name $_).Value
}
# remove unused variables
Remove-Variable modulus,publicExponent,privateExponent,prime1,prime2,exponent1,exponent2,coefficient -Force
# encode resulting set of INTEGERs to a SEQUENCE
$asnblob = Encode-Asn $asnblob 48
# build the encoded key
$key = New-Object Text.StringBuilder
if ($OutType -eq "Pkcs8") {
$asnblob = Encode-Asn $asnblob 4
$algid = [Security.Cryptography.CryptoConfig]::EncodeOID("1.2.840.113549.1.1.1") + 5,0
$algid = Encode-Asn $algid 48
$asnblob = 2,1,0 + $algid + $asnblob
$asnblob = Encode-Asn $asnblob 48
$base64 = [Convert]::ToBase64String($asnblob)
$base64 = $base64 -replace '(.{64}(?!$))',"`$1$([Environment]::NewLine)"
[void]$key.AppendLine("-----BEGIN PRIVATE KEY-----")
[void]$key.AppendLine($base64)
[void]$key.AppendLine("-----END PRIVATE KEY-----")
} else {
# PKCS#1 requires RSA identifier in the header.
# PKCS#1 is an inner structure of PKCS#8 message, therefore no additional encodings are required.
$base64 = [Convert]::ToBase64String($asnblob)
$base64 = $base64 -replace '(.{64}(?!$))',"`$1$([Environment]::NewLine)"
[void]$key.AppendLine("-----BEGIN RSA PRIVATE KEY-----")
[void]$key.AppendLine($base64)
[void]$key.AppendLine("-----END RSA PRIVATE KEY-----")
}
# build the encoded cert
$cert = New-Object Text.StringBuilder
$base64 = [Convert]::ToBase64String($Certificate.RawData)
$base64 = $base64 -replace '(.{64}(?!$))',"`$1$([Environment]::NewLine)"
[void]$cert.AppendLine("-----BEGIN CERTIFICATE-----")
[void]$cert.AppendLine($base64)
[void]$cert.AppendLine("-----END CERTIFICATE-----")
# build the encoded chain
if ($IncludeChain) {
$chainStr = New-Object Text.StringBuilder
$chain = New-Object Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = "NoCheck"
if ($certs) {
$chain.ChainPolicy.ExtraStore.AddRange($certs)
}
[void]$chain.Build($Certificate)
for ($n = 1; $n -lt $chain.ChainElements.Count; $n++) {
$base64 = [Convert]::ToBase64String($chain.ChainElements[$n].Certificate.RawData)
$base64 = $base64 -replace '(.{64}(?!$))',"`$1$([Environment]::NewLine)"
[void]$chainStr.AppendLine("-----BEGIN CERTIFICATE-----")
[void]$chainStr.AppendLine($base64)
[void]$chainStr.AppendLine("-----END CERTIFICATE-----")
}
}
# build the output paths
$outPath = Resolve-Path $OutFolder
if (!$OutName) {
if ($PsCmdlet.ParameterSetName -eq '__pfxfile') {
$OutName = $pfxFile.BaseName
} else {
$OutName = $Certificate.Thumbprint
}
}
$certPath = Join-Path $outPath "$OutName.crt"
$keyPath = Join-Path $outPath "$OutName.key"
$chainPath = Join-Path $outPath "$OutName-chain.pem"
# write files as necessary
Write-Host "Writing $certPath"
$cert.ToString() | Out-File $certPath -Encoding ascii -NoNewline
Write-Host "Writing $keyPath"
$key.ToString() | Out-File $keyPath -Encoding ascii -NoNewline
if ($IncludeChain) {
Write-Host "Writing $chainPath"
$key.ToString() | Out-File $chainPath -Encoding ascii -NoNewline
$cert.ToString() | Out-File $chainPath -Append -Encoding ascii -NoNewline
$chainStr.ToString() | Out-File $chainPath -Append -Encoding ascii -NoNewline
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment