Last active
October 3, 2025 14:08
-
-
Save jcoehoorn/557b877510808ea812157aaaf3729f36 to your computer and use it in GitHub Desktop.
Create Linux Apache certs/key from a password protected Windows pfx with only Powershell 5 (no openssl or .Net Core)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # This may need to run as administrator. | |
| <# | |
| The idea here is avoiding tools that cannot be assumed to be available, | |
| especially tools that are not serviced/patched **as a matter of course.** | |
| For example, this could be much simplier with OpenSSL. The two functions below, along with a couple dozen lines that use | |
| them, would then reduce to a single openssl call, with maybe a handful of lines of extra work around making the call. | |
| However, the point of this process is improving automation. | |
| OpenSSL, particularly, needs regular service updates/patches to avoid publishing certificates that may continue | |
| to have vulnerabilities over time. Adding openssl here in a way that is not serviced as a matter of course | |
| (say via Windows Update) would therefore defeat the purpose, since it would require occasional manual human | |
| interacion to update and maintain this dependency. Or we could add WinGet to the server, and include openssl | |
| that way. Then WinGet would update openssl, and windows update would handle winget (nothing is missed). | |
| But WinGet adds a significant (and uncessary) attack surface. And while there is a counter-argument that if | |
| an attacker gains access to manipulate winget, we've already lost, that misses the point of avoiding dependencies. | |
| If I can do this without WinGet/openssl (and I can!), then I should. | |
| Ultimately, the better irmpovement is getting up to Powershell 7, which uses a .Net version with some easier tools. | |
| #> | |
| $basePath = "C:\YourPath" | |
| $baseName = "cert_name" | |
| $pfxPassword = "some password" # Note: example only. Don't actually save this in a script. This variable name IS used later, but think of it as a placeholder. | |
| $pfxPath = "$basePath\$baseName.pfx" | |
| $keyPath = "$basePath\$baseName.key" | |
| $crtPath = "$basePath\$baseName.crt" | |
| $chainPemPath = "$basePath\$($baseName)_chain.pem" | |
| $keyPass = ConvertTo-SecureString $pfxPassword -AsPlainText -Force | |
| # Google Gemini assisted writing these two functions, but was actually kind of awful at it. | |
| # It especially had trouble resolving the issue with the single byte/array problem. | |
| # This function handles the complex task of ASN.1 encoding | |
| function Encode-Asn1 { | |
| param($type, $data) | |
| #Common Type-Length-Value encoding | |
| $encoded = New-Object System.IO.MemoryStream | |
| # Type | |
| $encoded.WriteByte($type) | |
| # Length | |
| $length = $data.Length | |
| if ($length -lt 0x80) { # If length is less than 128, write the length part of the structure as a single byte | |
| $encoded.WriteByte($length) | |
| } else { # Otherwise we need the longer encoding | |
| # Handle multi-byte length fields for large data | |
| $bytes = [System.BitConverter]::GetBytes($length) | |
| if ($bytes -isnot [System.Array]) { | |
| # Somehow we sometimes end up with a single byte, instead of an Array. | |
| # This shouldn't be possible ($length should always be a four-byte integer) and | |
| # I haven't been able to determine why, but it definitely happens. | |
| # This conditional adjustment seems to correct it. | |
| $bytes = @($bytes) | |
| } | |
| [Array]::Reverse($bytes) # Endian adjustment | |
| $lengthBytes = $bytes | Where-Object { $_ -ne 0 } | |
| $encoded.WriteByte(0x80 + $lengthBytes.Length) | |
| $encoded.Write($lengthBytes, 0, $lengthBytes.Length) | |
| } | |
| # Value | |
| $encoded.Write($data, 0, $data.Length) | |
| return $encoded.ToArray() | |
| } | |
| # This function ensures numbers are encoded correctly in big-endian format | |
| function Encode-Asn1Integer { | |
| param([byte[]]$value) | |
| # Trim leading zeros | |
| $start = 0 | |
| while ($start -lt $value.Length -and $value[$start] -eq 0) { | |
| $start++ | |
| } | |
| if ($start -eq $value.Length) { | |
| return Encode-Asn1 0x02 @(0) # Value is zero | |
| } | |
| $trimmedValue = $value[$start..($value.Length - 1)] | |
| # Add leading zero if high bit is 1 to signify a positive integer | |
| if (($trimmedValue[0] -band 0x80) -ne 0) { | |
| $trimmedValue = @(0) + $trimmedValue | |
| } | |
| return Encode-Asn1 0x02 $trimmedValue | |
| } | |
| # Import the PFX into a temporary X509Certificate2 object | |
| $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($pfxPath, $keyPass, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable) | |
| # Load the chain separately | |
| $chain = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection | |
| $chain.Import($pfxPath, $pfxPassword, "Exportable") | |
| $chain = $chain | Where-Object { -not $_.HasPrivateKey } | |
| # Derive the private key in the correct format | |
| # This is more complicate/low-level than it needs to be, because of the dependency limitations. | |
| # Export the raw key parameters. The 'true' gets the private components. | |
| $rsaParams = $cert.PrivateKey.ExportParameters($true) | |
| # Build the PKCS#1 structure as a sequence of ASN.1 integers | |
| $pkcs1Stream = New-Object System.IO.MemoryStream | |
| $pkcs1Stream.Write((Encode-Asn1Integer @(0)), 0, (Encode-Asn1Integer @(0)).Length) # Version | |
| @( | |
| $rsaParams.Modulus, | |
| $rsaParams.Exponent, | |
| $rsaParams.D, | |
| $rsaParams.P, | |
| $rsaParams.Q, | |
| $rsaParams.DP, | |
| $rsaParams.DQ, | |
| $rsaParams.InverseQ | |
| ) | ForEach-Object { | |
| $encodedInt = Encode-Asn1Integer $_ | |
| $pkcs1Stream.Write($encodedInt, 0, $encodedInt.Length) | |
| } | |
| $pkcs1Data = Encode-Asn1 0x30 $pkcs1Stream.ToArray() | |
| # Convert to Base64 and wrap in PEM headers | |
| $rsaKey = "-----BEGIN RSA PRIVATE KEY-----`n" | |
| $rsaKey += [System.Convert]::ToBase64String($pkcs1Data, [System.Base64FormattingOptions]::InsertLineBreaks) | |
| $rsaKey += "`n-----END RSA PRIVATE KEY-----" | |
| # Now I can write to file | |
| Set-Content -Path $keyPath -Value $rsaKey -Encoding Ascii | |
| # and the matching certificate data | |
| $certPem = "-----BEGIN CERTIFICATE-----`n" | |
| $certPem += [System.Convert]::ToBase64String($cert.RawData, "InsertLineBreaks") | |
| $certPem += "`n-----END CERTIFICATE-----" | |
| $certPem | Out-File $crtPath | |
| # Finally, build the intermediate cert chain | |
| # Combine the certs | |
| $pemChain = foreach ($ct in $chain) { | |
| "-----BEGIN CERTIFICATE-----" | |
| # Convert the certificate's raw binary data to a Base64 string with line breaks. | |
| [System.Convert]::ToBase64String($ct.RawData, "InsertLineBreaks") | |
| "-----END CERTIFICATE-----" | |
| } | |
| # Then save the combined PEM strings to the output file | |
| $pemChain | Out-File -FilePath $chainPemPath -Encoding Ascii | |
| # Note: use these files immediately! Don't leave them lying around more than absolutely necessary. | |
| # The actual final production version of this even deletes them. | |
| # If we need them again, we can request duplicates from the CA or rerun the script |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is a reference for using powershell (specifically, powershell 5) to convert a password-protected pfx certificate (with private key and trust chain) to a certificate/key pair plus trust chain suitable for use with linux/apache, without any other dependencies.