#Requires -Version 5.1 <# .SYNOPSIS Builds a PFX (PKCS#12) file from a certificate, its private key and an optional chain. .DESCRIPTION No external dependencies: no OpenSSL, no DLL, nothing but Windows built-ins (.NET Framework). Started without parameters it opens a small window; with parameters it runs on the console. Accepted input: Certificate .cer/.crt/.pem (PEM or DER), also .p7b, and files holding leaf plus chain Private key .key/.pem - PKCS#1 ("BEGIN RSA PRIVATE KEY") and PKCS#8 ("BEGIN PRIVATE KEY"), either one also encrypted with a passphrase. RSA only. Chain any number of additional files (PEM bundle, DER, P7B), or resolved automatically from the Windows certificate store and the AIA address The result works anywhere a PFX is expected - IIS, Exchange, a load balancer, a Java keystore conversion, an appliance upload. .PARAMETER CertPath the certificate (leaf) .PARAMETER KeyPath the private key .PARAMETER KeyPassword passphrase of the key (SecureString), if it is encrypted .PARAMETER ChainPath additional files holding intermediate/root certificates .PARAMETER OutPath the PFX file to write .PARAMETER PfxPassword password for the PFX (SecureString) .PARAMETER AutoChain resolve missing chain links via store/AIA (default: on) .PARAMETER IncludeRoot put the root certificate into the PFX as well (default: off, rarely needed) .PARAMETER FriendlyName display name in the certificate store .PARAMETER Gui force the window .EXAMPLE .\New-PfxFile.ps1 .\New-PfxFile.ps1 -CertPath mail.cer -KeyPath mail.key -OutPath mail.pfx -PfxPassword (Read-Host -AsSecureString) #> [CmdletBinding(DefaultParameterSetName = 'Gui')] param( [Parameter(ParameterSetName = 'Cli', Mandatory = $true)] [string]$CertPath, [Parameter(ParameterSetName = 'Cli', Mandatory = $true)] [string]$KeyPath, [Parameter(ParameterSetName = 'Cli')] [securestring]$KeyPassword, [Parameter(ParameterSetName = 'Cli')] [string[]]$ChainPath = @(), [Parameter(ParameterSetName = 'Cli', Mandatory = $true)] [string]$OutPath, [Parameter(ParameterSetName = 'Cli')] [securestring]$PfxPassword, [Parameter(ParameterSetName = 'Cli')] [bool]$AutoChain = $true, [Parameter(ParameterSetName = 'Cli')] [switch]$IncludeRoot, [Parameter(ParameterSetName = 'Cli')] [string]$FriendlyName, [Parameter(ParameterSetName = 'Gui')] [switch]$Gui ) Set-StrictMode -Version 2 $ErrorActionPreference = 'Stop' $script:ToolVersion = '1.1.0' # Log sink: the console by default, the window's log box when the GUI is running. $script:LogSink = $null function Write-Step { param([string]$Message, [ValidateSet('INFO', 'OK', 'WARN', 'ERROR', 'STEP')][string]$Level = 'INFO') if ($script:LogSink) { & $script:LogSink $Message $Level; return } $c = switch ($Level) { 'OK' { 'Green' } 'WARN' { 'Yellow' } 'ERROR' { 'Red' } 'STEP' { 'Cyan' } default { 'Gray' } } Write-Host ("[{0,-5}] {1}" -f $Level, $Message) -ForegroundColor $c } #region ----------------------------------------------------------------- ASN.1 (DER) # Minimal DER reader - enough for PKCS#1/PKCS#8 keys, not a general purpose ASN.1 parser. function Read-DerTlv { <# Reads one TLV at $Offset. Returns tag, content bytes and the offset just behind it. #> param([byte[]]$Data, [int]$Offset) if ($Offset + 2 -gt $Data.Length) { throw "DER: unexpected end of data at offset $Offset." } $tag = $Data[$Offset] $i = $Offset + 1 $len = $Data[$i]; $i++ if ($len -band 0x80) { $n = $len -band 0x7F if ($n -eq 0 -or $n -gt 4) { throw "DER: unsupported length encoding ($n bytes)." } $len = 0 for ($k = 0; $k -lt $n; $k++) { $len = ($len -shl 8) -bor $Data[$i]; $i++ } } if ($i + $len -gt $Data.Length) { throw 'DER: declared length runs past the end of the data.' } $val = New-Object byte[] $len [Array]::Copy($Data, $i, $val, 0, $len) [pscustomobject]@{ Tag = $tag; Value = $val; Next = $i + $len } } function Get-DerSequenceItems { <# Splits the content of a SEQUENCE into its TLVs. #> param([byte[]]$SequenceContent) $items = @(); $o = 0 while ($o -lt $SequenceContent.Length) { $t = Read-DerTlv -Data $SequenceContent -Offset $o $items += $t $o = $t.Next } # Return object lists WITHOUT a leading comma: with one, a caller using @() would get an array # inside an array. The comma is only needed for byte[], to preserve the type. return $items } function ConvertTo-UnsignedBytes { <# DER INTEGER -> byte array without the sign byte, left padded to $Size. #> param([byte[]]$Value, [int]$Size = 0) $start = 0 while ($start -lt $Value.Length - 1 -and $Value[$start] -eq 0) { $start++ } $len = $Value.Length - $start if ($Size -le 0) { $Size = $len } if ($len -gt $Size) { throw "Number is longer ($len) than expected ($Size)." } $out = New-Object byte[] $Size [Array]::Copy($Value, $start, $out, $Size - $len, $len) return ,$out # without the comma PowerShell turns byte[] into Object[] } function ConvertFrom-DerOid { param([byte[]]$Value) if (-not $Value.Length) { return '' } # The parentheses are mandatory: the comma operator binds tighter than "%", so PowerShell # would otherwise compute (a, b) % 40 - a modulo on an array. $parts = @([int][math]::Floor($Value[0] / 40), ([int]$Value[0] % 40)) $cur = 0 for ($i = 1; $i -lt $Value.Length; $i++) { $cur = ($cur -shl 7) -bor ($Value[$i] -band 0x7F) if (-not ($Value[$i] -band 0x80)) { $parts += $cur; $cur = 0 } } return ($parts -join '.') } #endregion #region ----------------------------------------------------------------- PEM function Get-PemBlocks { <# Finds every "-----BEGIN x-----" block: label, header lines (Proc-Type/DEK-Info), raw bytes. #> param([string]$Text) $blocks = @() $rx = [regex]'(?ms)^-{5}BEGIN ([A-Z0-9 ]+)-{5}\r?\n(.*?)^-{5}END \1-{5}' foreach ($m in $rx.Matches($Text)) { $label = $m.Groups[1].Value.Trim() $body = $m.Groups[2].Value $headers = @{} $b64 = New-Object Text.StringBuilder foreach ($line in ($body -split "`r?`n")) { $l = $line.Trim() if (-not $l) { continue } if ($l -match '^([A-Za-z-]+):\s*(.+)$' -and $l -notmatch '^[A-Za-z0-9+/=]+$') { $headers[$matches[1]] = $matches[2].Trim(); continue } [void]$b64.Append($l) } $blocks += [pscustomobject]@{ Label = $label Headers = $headers Bytes = [Convert]::FromBase64String($b64.ToString()) } } return $blocks } function Read-FileBytes { param([string]$Path) return ,([IO.File]::ReadAllBytes($Path)) } function Test-IsTextFile { param([byte[]]$Bytes) if (-not $Bytes.Length) { return $false } $take = [math]::Min(400, $Bytes.Length) $txt = [Text.Encoding]::ASCII.GetString($Bytes, 0, $take) return ($txt -match '-----BEGIN ') } #endregion #region ----------------------------------------------------------------- Private key function ConvertFrom-SecureStringPlain { <# WATCH OUT: X509Certificate2Collection.Import/Export have NO SecureString overload (only X509Certificate2 itself does). Hand one in anyway and PowerShell quietly stringifies it to "System.Security.SecureString" - the PFX would then carry a completely different password than the one that was typed. So these calls deliberately get plain text. #> param([securestring]$Secure) if (-not $Secure) { return '' } $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) } } function Get-PasswordBytes { param([securestring]$Secure) if (-not $Secure) { return $null } $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return ,([Text.Encoding]::UTF8.GetBytes([Runtime.InteropServices.Marshal]::PtrToStringBSTR($b))) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) } } function Get-Pbkdf2Bytes { <# PBKDF2 with a selectable HMAC. Rfc2898DeriveBytes on .NET Framework only does SHA-1. #> param([byte[]]$Password, [byte[]]$Salt, [int]$Iterations, [int]$Length, [string]$Prf = 'SHA1') $hmac = switch ($Prf) { 'SHA256' { New-Object Security.Cryptography.HMACSHA256(, $Password) } 'SHA512' { New-Object Security.Cryptography.HMACSHA512(, $Password) } 'SHA384' { New-Object Security.Cryptography.HMACSHA384(, $Password) } default { New-Object Security.Cryptography.HMACSHA1(, $Password) } } try { $hLen = $hmac.HashSize / 8 $blocks = [math]::Ceiling($Length / $hLen) $out = New-Object byte[] ($blocks * $hLen) for ($i = 1; $i -le $blocks; $i++) { $seed = New-Object byte[] ($Salt.Length + 4) [Array]::Copy($Salt, $seed, $Salt.Length) $seed[$Salt.Length] = [byte](($i -shr 24) -band 0xFF) $seed[$Salt.Length + 1] = [byte](($i -shr 16) -band 0xFF) $seed[$Salt.Length + 2] = [byte](($i -shr 8) -band 0xFF) $seed[$Salt.Length + 3] = [byte]($i -band 0xFF) $u = $hmac.ComputeHash($seed) $acc = $u.Clone() for ($j = 1; $j -lt $Iterations; $j++) { $u = $hmac.ComputeHash($u) for ($k = 0; $k -lt $hLen; $k++) { $acc[$k] = $acc[$k] -bxor $u[$k] } } [Array]::Copy($acc, 0, $out, ($i - 1) * $hLen, $hLen) } $res = New-Object byte[] $Length [Array]::Copy($out, $res, $Length) return ,$res } finally { $hmac.Dispose() } } function Get-OpenSslKdfBytes { <# Key derivation of the classic encrypted PEM format (Proc-Type/DEK-Info): a chain of MD5. #> param([byte[]]$Password, [byte[]]$Salt, [int]$Length) $md5 = [Security.Cryptography.MD5]::Create() try { $out = New-Object byte[] $Length $pos = 0; $prev = @() while ($pos -lt $Length) { $buf = New-Object byte[] ($prev.Length + $Password.Length + $Salt.Length) if ($prev.Length) { [Array]::Copy($prev, 0, $buf, 0, $prev.Length) } [Array]::Copy($Password, 0, $buf, $prev.Length, $Password.Length) [Array]::Copy($Salt, 0, $buf, $prev.Length + $Password.Length, $Salt.Length) $prev = $md5.ComputeHash($buf) $take = [math]::Min($prev.Length, $Length - $pos) [Array]::Copy($prev, 0, $out, $pos, $take) $pos += $take } return ,$out } finally { $md5.Dispose() } } function Invoke-SymmetricDecrypt { param([byte[]]$Data, [byte[]]$Key, [byte[]]$Iv, [ValidateSet('AES', '3DES', 'DES')][string]$Algorithm) $alg = switch ($Algorithm) { 'AES' { New-Object Security.Cryptography.AesManaged } '3DES' { New-Object Security.Cryptography.TripleDESCryptoServiceProvider } 'DES' { New-Object Security.Cryptography.DESCryptoServiceProvider } } try { $alg.Mode = 'CBC'; $alg.Padding = 'PKCS7'; $alg.Key = $Key; $alg.IV = $Iv $dec = $alg.CreateDecryptor() try { return ,($dec.TransformFinalBlock($Data, 0, $Data.Length)) } finally { $dec.Dispose() } } catch [Security.Cryptography.CryptographicException] { throw 'The private key could not be decrypted - the passphrase is most likely wrong.' } finally { $alg.Dispose() } } function Get-CipherInfo { <# Name from DEK-Info or an OID -> algorithm, key size and IV size. #> param([string]$Name) switch -Regex ($Name) { '^AES-128-CBC$|2\.16\.840\.1\.101\.3\.4\.1\.2' { return @{ Alg = 'AES'; KeySize = 16; IvSize = 16 } } '^AES-192-CBC$|2\.16\.840\.1\.101\.3\.4\.1\.22' { return @{ Alg = 'AES'; KeySize = 24; IvSize = 16 } } '^AES-256-CBC$|2\.16\.840\.1\.101\.3\.4\.1\.42' { return @{ Alg = 'AES'; KeySize = 32; IvSize = 16 } } '^DES-EDE3-CBC$|1\.2\.840\.113549\.3\.7' { return @{ Alg = '3DES'; KeySize = 24; IvSize = 8 } } '^DES-CBC$|1\.3\.14\.3\.2\.7' { return @{ Alg = 'DES'; KeySize = 8; IvSize = 8 } } default { throw "Unsupported key encryption algorithm: $Name" } } } function ConvertFrom-Pkcs1 { <# PKCS#1 RSAPrivateKey (DER) -> RSAParameters #> param([byte[]]$Der) $seq = Read-DerTlv -Data $Der -Offset 0 if ($seq.Tag -ne 0x30) { throw 'Key: expected a SEQUENCE - this is not a valid PKCS#1 structure.' } $it = @(Get-DerSequenceItems -SequenceContent $seq.Value) if ($it.Count -lt 9) { throw "Key: PKCS#1 expects 9 fields, found $($it.Count). Only RSA is supported." } $n = ConvertTo-UnsignedBytes $it[1].Value $k = $n.Length; $h = [int][math]::Ceiling($k / 2) $p = New-Object Security.Cryptography.RSAParameters $p.Modulus = $n $p.Exponent = ConvertTo-UnsignedBytes $it[2].Value $p.D = ConvertTo-UnsignedBytes $it[3].Value $k $p.P = ConvertTo-UnsignedBytes $it[4].Value $h $p.Q = ConvertTo-UnsignedBytes $it[5].Value $h $p.DP = ConvertTo-UnsignedBytes $it[6].Value $h $p.DQ = ConvertTo-UnsignedBytes $it[7].Value $h $p.InverseQ = ConvertTo-UnsignedBytes $it[8].Value $h return $p } function ConvertFrom-Pkcs8 { <# PKCS#8 PrivateKeyInfo (DER) -> RSAParameters #> param([byte[]]$Der) $seq = Read-DerTlv -Data $Der -Offset 0 $it = @(Get-DerSequenceItems -SequenceContent $seq.Value) if ($it.Count -lt 3) { throw 'Key: incomplete PKCS#8 structure.' } $algItems = @(Get-DerSequenceItems -SequenceContent $it[1].Value) $oid = ConvertFrom-DerOid $algItems[0].Value if ($oid -ne '1.2.840.113549.1.1.1') { throw "The key is not an RSA key (OID $oid). This tool supports RSA only." } return (ConvertFrom-Pkcs1 -Der $it[2].Value) } function ConvertFrom-EncryptedPkcs8 { <# EncryptedPrivateKeyInfo using PBES2 (PBKDF2 + AES/3DES-CBC) -> RSAParameters #> param([byte[]]$Der, [byte[]]$Password) if (-not $Password) { throw 'The private key is encrypted - please supply the passphrase.' } $seq = Read-DerTlv -Data $Der -Offset 0 $it = @(Get-DerSequenceItems -SequenceContent $seq.Value) $algItems = @(Get-DerSequenceItems -SequenceContent $it[0].Value) $schemeOid = ConvertFrom-DerOid $algItems[0].Value if ($schemeOid -ne '1.2.840.113549.1.5.13') { throw "Unsupported key encryption scheme (OID $schemeOid). Please supply the key unencrypted." } $pbes2 = @(Get-DerSequenceItems -SequenceContent $algItems[1].Value) # { KDF, cipher } $kdf = @(Get-DerSequenceItems -SequenceContent $pbes2[0].Value) # { OID pbkdf2, params } if ((ConvertFrom-DerOid $kdf[0].Value) -ne '1.2.840.113549.1.5.12') { throw 'Unsupported key derivation (PBKDF2 expected).' } $kdfP = @(Get-DerSequenceItems -SequenceContent $kdf[1].Value) # { salt, iter, [keylen], [prf] } $salt = $kdfP[0].Value $iter = [int](New-Object Numerics.BigInteger(, ([byte[]]($kdfP[1].Value[($kdfP[1].Value.Length - 1)..0])))) $prf = 'SHA1' foreach ($p in $kdfP) { if ($p.Tag -eq 0x30) { $inner = @(Get-DerSequenceItems -SequenceContent $p.Value) switch (ConvertFrom-DerOid $inner[0].Value) { '1.2.840.113549.2.9' { $prf = 'SHA256' } '1.2.840.113549.2.11' { $prf = 'SHA512' } '1.2.840.113549.2.10' { $prf = 'SHA384' } } } } $cipher = @(Get-DerSequenceItems -SequenceContent $pbes2[1].Value) # { OID, IV } $info = Get-CipherInfo (ConvertFrom-DerOid $cipher[0].Value) $iv = $cipher[1].Value $key = Get-Pbkdf2Bytes -Password $Password -Salt $salt -Iterations $iter -Length $info.KeySize -Prf $prf $plain = Invoke-SymmetricDecrypt -Data $it[1].Value -Key $key -Iv $iv -Algorithm $info.Alg return (ConvertFrom-Pkcs8 -Der $plain) } function Import-PrivateKey { <# Reads an RSA private key from a file (PEM or DER) and returns RSAParameters. Handles PKCS#1 and PKCS#8, encrypted or not. #> param([string]$Path, [securestring]$Password) if (-not (Test-Path -LiteralPath $Path)) { throw "Key file not found: $Path" } $raw = Read-FileBytes $Path $pw = Get-PasswordBytes $Password if (-not (Test-IsTextFile $raw)) { # DER: try PKCS#8 first, then PKCS#1 try { return (ConvertFrom-Pkcs8 -Der $raw) } catch { } return (ConvertFrom-Pkcs1 -Der $raw) } $text = [Text.Encoding]::UTF8.GetString($raw) $blocks = @(Get-PemBlocks -Text $text) if (-not $blocks.Count) { throw "No PEM block found in $([IO.Path]::GetFileName($Path))." } $keyBlock = $blocks | Where-Object { $_.Label -match 'PRIVATE KEY' } | Select-Object -First 1 if (-not $keyBlock) { throw "$([IO.Path]::GetFileName($Path)) contains no private key (found: $(($blocks | ForEach-Object Label) -join ', '))." } switch -Regex ($keyBlock.Label) { 'ENCRYPTED PRIVATE KEY' { return (ConvertFrom-EncryptedPkcs8 -Der $keyBlock.Bytes -Password $pw) } 'RSA PRIVATE KEY' { if ($keyBlock.Headers.ContainsKey('DEK-Info')) { if (-not $pw) { throw 'The private key is encrypted - please supply the passphrase.' } $parts = $keyBlock.Headers['DEK-Info'] -split ',' $info = Get-CipherInfo $parts[0].Trim() $iv = [byte[]]( ($parts[1].Trim() -split '(..)' | Where-Object { $_ }) | ForEach-Object { [Convert]::ToByte($_, 16) } ) $salt = $iv[0..7] $key = Get-OpenSslKdfBytes -Password $pw -Salt $salt -Length $info.KeySize $plain = Invoke-SymmetricDecrypt -Data $keyBlock.Bytes -Key $key -Iv $iv -Algorithm $info.Alg return (ConvertFrom-Pkcs1 -Der $plain) } return (ConvertFrom-Pkcs1 -Der $keyBlock.Bytes) } 'PRIVATE KEY' { return (ConvertFrom-Pkcs8 -Der $keyBlock.Bytes) } default { throw "Unknown key type: $($keyBlock.Label)" } } } #endregion #region ----------------------------------------------------------------- Certificates function Import-Certificates { <# Reads every certificate in a file: PEM (bundle too), DER or PKCS#7 (.p7b). #> param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" } $raw = Read-FileBytes $Path $certs = @() if (Test-IsTextFile $raw) { foreach ($b in (Get-PemBlocks -Text ([Text.Encoding]::UTF8.GetString($raw)))) { if ($b.Label -match 'CERTIFICATE' -and $b.Label -notmatch 'REQUEST') { $certs += New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $b.Bytes) } } if ($certs.Count) { return $certs } throw "No certificate found in $([IO.Path]::GetFileName($Path))." } # DER or PKCS#7 try { $coll = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection $coll.Import($raw) # handles single DER as well as P7B if ($coll.Count) { return @($coll) } } catch { } return @(New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $raw)) } function Select-LeafCertificate { <# Picks the end entity certificate out of a file holding several: the one that issued none of the others. (Reading basic constraints would be clumsier and breaks when the extension is absent.) #> param($Certificates) $certs = @($Certificates) if ($certs.Count -eq 1) { return $certs[0] } $issuers = @($certs | ForEach-Object { $_.Issuer }) $cand = @($certs | Where-Object { $issuers -notcontains $_.Subject }) if (-not $cand.Count) { $cand = $certs } return @($cand | Sort-Object NotAfter -Descending)[0] } function Test-KeyMatchesCertificate { <# Compares the public key inside the certificate with the private key. #> param($Certificate, [Security.Cryptography.RSAParameters]$KeyParameters) $pub = $Certificate.PublicKey.Key if ($pub -isnot [Security.Cryptography.RSA]) { return $false } $certMod = $pub.ExportParameters($false).Modulus $keyMod = $KeyParameters.Modulus if ($certMod.Length -ne $keyMod.Length) { return $false } $diff = 0 for ($i = 0; $i -lt $certMod.Length; $i++) { $diff = $diff -bor ($certMod[$i] -bxor $keyMod[$i]) } return ($diff -eq 0) } function Get-AiaUrls { <# caIssuers addresses from the Authority Information Access extension (1.3.6.1.5.5.7.1.1). #> param($Certificate) $ext = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '1.3.6.1.5.5.7.1.1' } | Select-Object -First 1 if (-not $ext) { return @() } $txt = $ext.Format($true) return @([regex]::Matches($txt, 'https?://\S+') | ForEach-Object { $_.Value.TrimEnd(')', ',', ';') } | Where-Object { $_ -notmatch '/ocsp' } | Select-Object -Unique) } function Resolve-CertificateChain { <# Works out the chain for the leaf: from the supplied certificates and, with $AutoChain, also from the Windows certificate store and the AIA address (Windows chain building does that on its own as long as the machine is online). Returns the intermediates (without the leaf), the root separately, plus status information. #> param($Leaf, $Extra = @(), [bool]$AutoChain = $true) $chain = New-Object Security.Cryptography.X509Certificates.X509Chain $chain.ChainPolicy.RevocationMode = 'NoCheck' $chain.ChainPolicy.VerificationFlags = 'IgnoreNotTimeValid,IgnoreCtlNotTimeValid,IgnoreInvalidBasicConstraints,IgnoreWrongUsage,IgnoreInvalidName,IgnoreInvalidPolicy' foreach ($c in $Extra) { [void]$chain.ChainPolicy.ExtraStore.Add($c) } [void]$chain.Build($Leaf) $elements = @($chain.ChainElements | ForEach-Object { $_.Certificate }) $inter = @(); $root = $null if ($elements.Count -gt 1) { for ($i = 1; $i -lt $elements.Count; $i++) { $c = $elements[$i] if ($c.Subject -eq $c.Issuer) { $root = $c } else { $inter += $c } } } $statuses = @($chain.ChainStatus | ForEach-Object { $_.Status } | Select-Object -Unique) [pscustomobject]@{ Intermediates = $inter Root = $root Complete = (-not ($statuses -contains 'PartialChain')) Status = $statuses AiaUrls = @(Get-AiaUrls -Certificate $Leaf) } } function Get-CertificateFromUrl { <# Downloads a certificate from an AIA address (DER, PEM or P7B). #> param([string]$Url) $tmp = [IO.Path]::GetTempFileName() try { $old = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' try { Invoke-WebRequest -Uri $Url -OutFile $tmp -UseBasicParsing -TimeoutSec 20 } finally { $ProgressPreference = $old } return @(Import-Certificates -Path $tmp) } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } } #endregion #region ----------------------------------------------------------------- Building the PFX function New-PfxBytes { <# Builds the PFX: leaf with its private key plus the chain certificates. The key is briefly placed into a named CSP container - on .NET Framework that is the only way to export a PFX carrying a private key - and removed again afterwards. #> param( $Leaf, [Security.Cryptography.RSAParameters]$KeyParameters, $ChainCerts = @(), [securestring]$Password, [string]$FriendlyName ) $csp = New-Object Security.Cryptography.CspParameters $csp.KeyContainerName = "PfxBuild-" + [Guid]::NewGuid().ToString('N') $csp.KeyNumber = 1 # AT_KEYEXCHANGE $csp.Flags = [Security.Cryptography.CspProviderFlags]::UseMachineKeyStore $rsa = New-Object Security.Cryptography.RSACryptoServiceProvider($csp) try { $rsa.ImportParameters($KeyParameters) $withKey = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $Leaf.RawData) $withKey.PrivateKey = $rsa if ($FriendlyName) { $withKey.FriendlyName = $FriendlyName } $coll = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection [void]$coll.Add($withKey) foreach ($c in $ChainCerts) { if ($c) { [void]$coll.Add($c) } } # Plain text on purpose - see ConvertFrom-SecureStringPlain for why. $plain = ConvertFrom-SecureStringPlain $Password return ,($coll.Export([Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, $plain)) } finally { # do not leave the key container behind on the machine try { $rsa.PersistKeyInCsp = $false; $rsa.Clear() } catch { } } } function New-PfxFromFiles { <# The whole run. Returns a result object. #> param( [string]$CertPath, [string]$KeyPath, [securestring]$KeyPassword, [string[]]$ChainPath = @(), [string]$OutPath, [securestring]$PfxPassword, [bool]$AutoChain = $true, [bool]$IncludeRoot = $false, [string]$FriendlyName ) Write-Step "PFX builder $script:ToolVersion" STEP # 1. certificate $certs = @(Import-Certificates -Path $CertPath) $leaf = Select-LeafCertificate -Certificates $certs Write-Step "Certificate: $($leaf.Subject)" OK Write-Step " Issuer : $($leaf.Issuer)" INFO Write-Step " Valid to : $($leaf.NotAfter.ToString('yyyy-MM-dd')) | Serial: $($leaf.SerialNumber)" INFO if ($leaf.NotAfter -lt (Get-Date)) { Write-Step 'Careful: this certificate has already expired.' WARN } if ($leaf.NotBefore -gt (Get-Date)) { Write-Step 'Careful: this certificate is not valid yet.' WARN } $extraFromCertFile = @($certs | Where-Object { $_.Thumbprint -ne $leaf.Thumbprint }) if ($extraFromCertFile.Count) { Write-Step " The certificate file holds $($extraFromCertFile.Count) more certificate(s) - using them as chain." INFO } # 2. private key $keyParams = Import-PrivateKey -Path $KeyPath -Password $KeyPassword $bits = $keyParams.Modulus.Length * 8 Write-Step "Private key read (RSA, $bits bit)" OK # 3. do they belong together - the one check that matters most if (-not (Test-KeyMatchesCertificate -Certificate $leaf -KeyParameters $keyParams)) { throw 'This private key does NOT belong to this certificate (the public keys differ).' } Write-Step 'Key and certificate belong together.' OK # 4. chain $extra = @($extraFromCertFile) foreach ($p in $ChainPath) { if (-not $p) { continue } $c = @(Import-Certificates -Path $p) Write-Step "Chain file $([IO.Path]::GetFileName($p)): $($c.Count) certificate(s)" INFO $extra += $c } $res = Resolve-CertificateChain -Leaf $leaf -Extra $extra -AutoChain $AutoChain if (-not $res.Complete -and $AutoChain -and $res.AiaUrls.Count) { Write-Step "Chain incomplete - downloading the missing intermediate from $($res.AiaUrls[0]) ..." WARN try { $downloaded = @(Get-CertificateFromUrl -Url $res.AiaUrls[0]) foreach ($d in $downloaded) { Write-Step " downloaded: $($d.Subject)" OK } $extra += $downloaded $res = Resolve-CertificateChain -Leaf $leaf -Extra $extra -AutoChain $AutoChain } catch { Write-Step "Download failed: $($_.Exception.Message)" WARN } } foreach ($c in $res.Intermediates) { Write-Step "Chain: $($c.Subject)" OK } if ($res.Root) { Write-Step "Root: $($res.Root.Subject)$(if (-not $IncludeRoot) { ' (left out of the PFX)' })" INFO } if (-not $res.Complete) { Write-Step 'The chain is incomplete - at least one intermediate is missing. Servers will happily bind such a certificate, but clients will reject it.' WARN } foreach ($s in ($res.Status | Where-Object { $_ -ne 'NoError' })) { Write-Step "Chain note: $s" INFO } # 5. write it $chainCerts = @($res.Intermediates) if ($IncludeRoot -and $res.Root) { $chainCerts += $res.Root } if (-not $FriendlyName) { $cn = ($leaf.Subject -split ',' | Where-Object { $_.Trim() -like 'CN=*' } | Select-Object -First 1) $FriendlyName = if ($cn) { $cn.Trim().Substring(3) } else { 'Certificate' } } $bytes = New-PfxBytes -Leaf $leaf -KeyParameters $keyParams -ChainCerts $chainCerts -Password $PfxPassword -FriendlyName $FriendlyName [IO.File]::WriteAllBytes($OutPath, $bytes) Write-Step "PFX written: $OutPath ($($bytes.Length) bytes, $(1 + $chainCerts.Count) certificate(s))" OK # 6. read it back as a check $check = New-Object Security.Cryptography.X509Certificates.X509Certificate2Collection $check.Import($OutPath, (ConvertFrom-SecureStringPlain $PfxPassword), 'EphemeralKeySet') $reLeaf = @($check | Where-Object { $_.HasPrivateKey }) if (-not $reLeaf.Count) { throw 'The PFX that was written holds no private key - please report this.' } Write-Step "Verified: the PFX opens, $($check.Count) certificate(s), private key present." OK [pscustomobject]@{ Path = $OutPath; Thumbprint = $leaf.Thumbprint; Subject = $leaf.Subject NotAfter = $leaf.NotAfter; ChainCount = $chainCerts.Count; Complete = $res.Complete FriendlyName = $FriendlyName } } #endregion #region ----------------------------------------------------------------- Window function Show-Gui { Add-Type -AssemblyName System.Windows.Forms, System.Drawing [Windows.Forms.Application]::EnableVisualStyles() $f = New-Object Windows.Forms.Form $f.Text = "Build a PFX file $script:ToolVersion" $f.Size = New-Object Drawing.Size(900, 660) $f.MinimumSize = New-Object Drawing.Size(760, 560) $f.StartPosition = 'CenterScreen' $f.Font = New-Object Drawing.Font('Segoe UI', 9) $top = New-Object Windows.Forms.Panel $top.Dock = 'Top'; $top.Height = 300; $top.Padding = New-Object Windows.Forms.Padding(12, 10, 12, 0) $f.Controls.Add($top) function New-Row { param([string]$Label, [int]$Y) $l = New-Object Windows.Forms.Label $l.Text = $Label; $l.Location = New-Object Drawing.Point(12, ($Y + 4)); $l.Size = New-Object Drawing.Size(120, 20) $t = New-Object Windows.Forms.TextBox $t.Location = New-Object Drawing.Point(136, $Y); $t.Size = New-Object Drawing.Size(600, 23) $t.Anchor = 'Top,Left,Right' $b = New-Object Windows.Forms.Button $b.Text = '...'; $b.Location = New-Object Drawing.Point(742, ($Y - 1)); $b.Size = New-Object Drawing.Size(36, 25) $b.Anchor = 'Top,Right' $top.Controls.AddRange(@($l, $t, $b)) return [pscustomobject]@{ Label = $l; Text = $t; Button = $b } } function Select-File { param([string]$Filter, [switch]$Save, [switch]$Multi) $d = if ($Save) { New-Object Windows.Forms.SaveFileDialog } else { New-Object Windows.Forms.OpenFileDialog } $d.Filter = $Filter if ($Multi -and -not $Save) { $d.Multiselect = $true } if ($d.ShowDialog() -eq 'OK') { if ($Multi -and -not $Save) { return $d.FileNames } else { return $d.FileName } } return $null } $rCert = New-Row 'Certificate' 12 $rKey = New-Row 'Private key' 46 $rOut = New-Row 'PFX to write' 150 $lKp = New-Object Windows.Forms.Label $lKp.Text = 'Passphrase'; $lKp.Location = New-Object Drawing.Point(12, 84); $lKp.Size = New-Object Drawing.Size(120, 20) $tKp = New-Object Windows.Forms.TextBox $tKp.Location = New-Object Drawing.Point(136, 80); $tKp.Size = New-Object Drawing.Size(240, 23); $tKp.UseSystemPasswordChar = $true $lKpHint = New-Object Windows.Forms.Label $lKpHint.Text = 'only if the key is encrypted'; $lKpHint.ForeColor = 'DimGray' $lKpHint.Location = New-Object Drawing.Point(384, 84); $lKpHint.Size = New-Object Drawing.Size(300, 20) $top.Controls.AddRange(@($lKp, $tKp, $lKpHint)) $lCh = New-Object Windows.Forms.Label $lCh.Text = 'Chain (optional)'; $lCh.Location = New-Object Drawing.Point(12, 116); $lCh.Size = New-Object Drawing.Size(120, 20) $tCh = New-Object Windows.Forms.TextBox $tCh.Location = New-Object Drawing.Point(136, 112); $tCh.Size = New-Object Drawing.Size(600, 23); $tCh.Anchor = 'Top,Left,Right' $bCh = New-Object Windows.Forms.Button $bCh.Text = '...'; $bCh.Location = New-Object Drawing.Point(742, 111); $bCh.Size = New-Object Drawing.Size(36, 25); $bCh.Anchor = 'Top,Right' $top.Controls.AddRange(@($lCh, $tCh, $bCh)) $lPp = New-Object Windows.Forms.Label $lPp.Text = 'PFX password'; $lPp.Location = New-Object Drawing.Point(12, 188); $lPp.Size = New-Object Drawing.Size(120, 20) $tPp = New-Object Windows.Forms.TextBox $tPp.Location = New-Object Drawing.Point(136, 184); $tPp.Size = New-Object Drawing.Size(240, 23); $tPp.UseSystemPasswordChar = $true $lPp2 = New-Object Windows.Forms.Label $lPp2.Text = 'Repeat'; $lPp2.Location = New-Object Drawing.Point(384, 188); $lPp2.Size = New-Object Drawing.Size(80, 20) $tPp2 = New-Object Windows.Forms.TextBox $tPp2.Location = New-Object Drawing.Point(470, 184); $tPp2.Size = New-Object Drawing.Size(240, 23); $tPp2.UseSystemPasswordChar = $true $top.Controls.AddRange(@($lPp, $tPp, $lPp2, $tPp2)) $cAuto = New-Object Windows.Forms.CheckBox $cAuto.Text = 'Resolve missing chain links automatically (certificate store / AIA download)' $cAuto.Location = New-Object Drawing.Point(136, 216); $cAuto.Size = New-Object Drawing.Size(560, 22); $cAuto.Checked = $true $cRoot = New-Object Windows.Forms.CheckBox $cRoot.Text = 'Include the root certificate (rarely needed)' $cRoot.Location = New-Object Drawing.Point(136, 240); $cRoot.Size = New-Object Drawing.Size(560, 22) $top.Controls.AddRange(@($cAuto, $cRoot)) $bRun = New-Object Windows.Forms.Button $bRun.Text = 'Build PFX'; $bRun.Location = New-Object Drawing.Point(136, 266); $bRun.Size = New-Object Drawing.Size(140, 30) $bCheck = New-Object Windows.Forms.Button $bCheck.Text = 'Check only'; $bCheck.Location = New-Object Drawing.Point(286, 266); $bCheck.Size = New-Object Drawing.Size(110, 30) $lState = New-Object Windows.Forms.Label $lState.Location = New-Object Drawing.Point(406, 273); $lState.Size = New-Object Drawing.Size(360, 20); $lState.ForeColor = 'DimGray' $top.Controls.AddRange(@($bRun, $bCheck, $lState)) $log = New-Object Windows.Forms.RichTextBox $log.Dock = 'Fill'; $log.ReadOnly = $true; $log.WordWrap = $false $log.ScrollBars = 'Both'; $log.BackColor = 'White' $log.Font = New-Object Drawing.Font('Consolas', 9) $logHost = New-Object Windows.Forms.Panel $logHost.Dock = 'Fill'; $logHost.Padding = New-Object Windows.Forms.Padding(12, 6, 12, 12) $logHost.Controls.Add($log) $f.Controls.Add($logHost) $logHost.BringToFront() # Only script scope variables can be reached safely from inside event handlers. $script:LogBox = $log $script:LblState = $lState $script:BtnRun = $bRun $script:BtnCheck = $bCheck function Add-Log { param([string]$Text, [string]$Level = 'INFO') $box = $script:LogBox $color = switch ($Level) { 'OK' { [Drawing.Color]::FromArgb(0, 120, 60) } 'WARN' { [Drawing.Color]::FromArgb(180, 100, 0) } 'ERROR' { [Drawing.Color]::FromArgb(180, 30, 20) } 'STEP' { [Drawing.Color]::FromArgb(0, 70, 140) } default { [Drawing.Color]::FromArgb(60, 60, 60) } } $box.SelectionStart = $box.TextLength $box.SelectionLength = 0 $box.SelectionColor = $color $box.AppendText(("{0:HH:mm:ss} {1}" -f (Get-Date), $Text) + "`n") $box.SelectionColor = $box.ForeColor $box.ScrollToCaret() } $script:LogSink = { param($m, $l) Add-Log $m $l } $fCert = 'Certificates (*.cer;*.crt;*.pem;*.der;*.p7b)|*.cer;*.crt;*.pem;*.der;*.p7b|All files (*.*)|*.*' $fKey = 'Keys (*.key;*.pem;*.txt)|*.key;*.pem;*.txt|All files (*.*)|*.*' $rCert.Button.Add_Click({ $p = Select-File -Filter $fCert if ($p) { $rCert.Text.Text = $p if (-not $rOut.Text.Text) { $rOut.Text.Text = [IO.Path]::ChangeExtension($p, '.pfx') } if (-not $rKey.Text.Text) { $guess = [IO.Path]::ChangeExtension($p, '.key') if (Test-Path -LiteralPath $guess) { $rKey.Text.Text = $guess } } } }) $rKey.Button.Add_Click({ $p = Select-File -Filter $fKey; if ($p) { $rKey.Text.Text = $p } }) $rOut.Button.Add_Click({ $p = Select-File -Filter 'PFX (*.pfx)|*.pfx' -Save; if ($p) { $rOut.Text.Text = $p } }) $bCh.Add_Click({ $p = Select-File -Filter $fCert -Multi if ($p) { $tCh.Text = ($p -join '; ') } }) $script:Busy = $false function Invoke-Work { param([bool]$OnlyCheck) if ($script:Busy) { return } $cert = $rCert.Text.Text.Trim(); $key = $rKey.Text.Text.Trim(); $out = $rOut.Text.Text.Trim() if (-not $cert -or -not $key) { Add-Log 'Please choose a certificate and a private key.' 'ERROR'; return } if (-not $OnlyCheck) { if (-not $out) { Add-Log 'Please choose where to write the PFX.' 'ERROR'; return } if ($tPp.Text -ne $tPp2.Text) { Add-Log 'The two PFX passwords do not match.' 'ERROR'; return } if (-not $tPp.Text) { Add-Log 'A PFX without a password can be built, but almost every importer insists on one. Please set it.' 'ERROR'; return } } $script:Busy = $true $script:BtnRun.Enabled = $false; $script:BtnCheck.Enabled = $false $script:LblState.Text = if ($OnlyCheck) { 'Checking ...' } else { 'Building the PFX ...' } $chainFiles = @() if ($tCh.Text.Trim()) { $chainFiles = @($tCh.Text -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } $rs = [runspacefactory]::CreateRunspace() $rs.ApartmentState = 'STA'; $rs.ThreadOptions = 'ReuseThread'; $rs.Open() $rs.SessionStateProxy.SetVariable('ScriptPath', $PSCommandPath) # Do NOT call it 'P': PowerShell does not tell $P and $p apart, so a loop variable $p # would overwrite the parameter hashtable. $rs.SessionStateProxy.SetVariable('Job', @{ Cert = $cert; Key = $key; KeyPw = $tKp.Text; Out = $out; PfxPw = $tPp.Text Chain = $chainFiles; Auto = $cAuto.Checked; Root = $cRoot.Checked; OnlyCheck = $OnlyCheck }) $ps = [powershell]::Create() $ps.Runspace = $rs [void]$ps.AddScript({ # Load only the functions of the script - running the script itself would start over. $ast = [Management.Automation.Language.Parser]::ParseFile($ScriptPath, [ref]$null, [ref]$null) $fns = $ast.FindAll({ $args[0] -is [Management.Automation.Language.FunctionDefinitionAst] }, $false) . ([scriptblock]::Create((($fns | ForEach-Object { $_.Extent.Text }) -join "`r`n"))) Set-StrictMode -Version 2 $ErrorActionPreference = 'Stop' $script:ToolVersion = '1.1.0' $msgs = New-Object Collections.ArrayList $script:LogSink = { param($m, $l) [void]$msgs.Add(@($m, $l)) } $result = $null; $err = $null $toSec = { param($s) if ($s) { ConvertTo-SecureString $s -AsPlainText -Force } else { $null } } try { if ($Job.OnlyCheck) { $certs = @(Import-Certificates -Path $Job.Cert) $leaf = Select-LeafCertificate -Certificates $certs Write-Step "Certificate: $($leaf.Subject)" OK Write-Step " Issuer : $($leaf.Issuer)" INFO Write-Step " Valid : $($leaf.NotBefore.ToString('yyyy-MM-dd')) to $($leaf.NotAfter.ToString('yyyy-MM-dd'))" INFO $kp = Import-PrivateKey -Path $Job.Key -Password (& $toSec $Job.KeyPw) Write-Step "Private key read (RSA, $($kp.Modulus.Length * 8) bit)" OK if (Test-KeyMatchesCertificate -Certificate $leaf -KeyParameters $kp) { Write-Step 'Key and certificate belong together.' OK } else { Write-Step 'This private key does NOT belong to this certificate.' ERROR } $extra = @($certs | Where-Object { $_.Thumbprint -ne $leaf.Thumbprint }) foreach ($chainFile in $Job.Chain) { $extra += @(Import-Certificates -Path $chainFile) } $res = Resolve-CertificateChain -Leaf $leaf -Extra $extra -AutoChain $Job.Auto foreach ($c in $res.Intermediates) { Write-Step "Chain: $($c.Subject)" OK } if ($res.Root) { Write-Step "Root: $($res.Root.Subject)" INFO } if (-not $res.Complete) { Write-Step 'Chain incomplete - an intermediate is missing.' WARN } else { Write-Step 'Chain complete.' OK } } else { $result = New-PfxFromFiles -CertPath $Job.Cert -KeyPath $Job.Key -KeyPassword (& $toSec $Job.KeyPw) ` -ChainPath $Job.Chain -OutPath $Job.Out -PfxPassword (& $toSec $Job.PfxPw) ` -AutoChain $Job.Auto -IncludeRoot $Job.Root } } catch { $err = $_.Exception.Message } [pscustomobject]@{ Messages = $msgs; Result = $result; Error = $err } }) # An event handler does NOT run in the scope of the function that registered it, so local # variables would be invisible by the time it fires. Keep them in the script scope. $script:JobHandle = $ps.BeginInvoke() $script:JobPs = $ps $script:JobRs = $rs $script:JobTimer = New-Object Windows.Forms.Timer $script:JobTimer.Interval = 150 $script:JobTimer.Add_Tick({ if (-not $script:JobHandle.IsCompleted) { return } $script:JobTimer.Stop() try { $r = @($script:JobPs.EndInvoke($script:JobHandle))[-1] foreach ($m in $r.Messages) { Add-Log $m[0] $m[1] } if ($r.Error) { Add-Log $r.Error 'ERROR' $script:LblState.Text = 'Failed' } elseif ($r.Result) { Add-Log '' 'INFO' Add-Log 'Done. The PFX is ready at:' 'STEP' Add-Log " $($r.Result.Path)" 'INFO' Add-Log " Thumbprint $($r.Result.Thumbprint), valid to $($r.Result.NotAfter.ToString('yyyy-MM-dd'))" 'INFO' $script:LblState.Text = 'PFX created' } else { $script:LblState.Text = 'Check finished' } } catch { Add-Log "Unexpected error: $($_.Exception.Message)" 'ERROR' $script:LblState.Text = 'Failed' } finally { $script:JobPs.Dispose(); $script:JobRs.Close(); $script:JobRs.Dispose() $script:Busy = $false $script:BtnRun.Enabled = $true; $script:BtnCheck.Enabled = $true $script:JobTimer.Dispose() } }) $script:JobTimer.Start() } $bRun.Add_Click({ Invoke-Work -OnlyCheck $false }) $bCheck.Add_Click({ Invoke-Work -OnlyCheck $true }) Add-Log "PFX builder $script:ToolVersion - no external dependencies, Windows built-ins only." 'STEP' Add-Log 'Choose a certificate and its private key, set a password for the PFX, then "Build PFX".' 'INFO' Add-Log '"Check only" tells you up front whether key and certificate match and whether the chain is complete.' 'INFO' [void]$f.ShowDialog() $script:LogSink = $null } #endregion # ----------------------------------------------------------------- Entry point if ($PSCmdlet.ParameterSetName -eq 'Cli') { $r = New-PfxFromFiles -CertPath $CertPath -KeyPath $KeyPath -KeyPassword $KeyPassword ` -ChainPath $ChainPath -OutPath $OutPath -PfxPassword $PfxPassword ` -AutoChain $AutoChain -IncludeRoot $IncludeRoot.IsPresent -FriendlyName $FriendlyName $r } else { Show-Gui }