rmbolger / Posh-ACME

PowerShell module and ACME client to create certificates from Let's Encrypt (or other ACME CA)
https://poshac.me/docs/latest/
MIT License
770 stars 189 forks source link

FullChainFile doesn't contain ISRG Root X1 #539

Closed USSChris closed 6 months ago

USSChris commented 8 months ago

Not sure if this is intentional by Let's Encrypt, or by Posh-ACME, or just a misunderstanding I have of how things should work, but the base of the question is: Should $Cert.FullChainFile returned from $Cert = Get-PACertificate include the top level ISRG Root X1 Cert? The top cert I am seeing in there is the R3 cert.

A bit longer version - I've got some copiers running a client that connects to a server, this client forces an admin to accept the thumbprint of the top level cert in the chain. As I install the FullChainFile into the server, it currently only includes the R3 cert which expires in 2025. I would love to not have to go around to all of these devices and re-accept the new fingerprint when R3 gets renewed soon.

rmbolger commented 8 months ago

Hey @USSChris, thanks for reaching out. It is indeed intentional by Let's Encrypt. Posh-ACME includes whatever intermediate certs the ACME server delivers along with the leaf certificate. But the root cert isn't included by most CAs because it is not typically served with the chain on a standard web server. Web clients are assumed to have the root CAs already in a local trust store. So there's normally no need for the server to waste bandwidth sending it.

If you've been using Let's Encrypt prior to Feb 8, you may have previously had what looked like the ISRG Root X1 cert included in the default chain. But it actually wasn't the root in that case. It was a cross-signed version of the root and the real root in that chain is the now long expired DST Root CA X3. But LE shortened the default chain being served on Feb 8 so that ISRG Root X1 is now that default root CA and there's only 1 intermediate which is currently R3 (unless you're on the ECDSA intermediate in which case it is E1). More background on the chain change is here: https://letsencrypt.org/2023/07/10/cross-sign-expiration.html

Technically, you can still get the original long chain by using the PreferredChain parameter. But that will only work until June 6 when LE is scheduled to stop serving the long chain for good.

So long story short, what you're seeing is expected. Though it doesn't help with your copier that operates differently than the typical web server. Unfortunately from an ACME protocol perspective, there's no easy way to discover and download the root CA from an ACME server. That would have to be custom code written for every individual CA. But there's nothing stopping you personally from adding additional PowerShell to your automation that downloads the root and inserts it into the resulting PFX file. Official links to the LE roots can be found here: https://letsencrypt.org/certificates/

WhileDekker commented 7 months ago

@rmbolger Thanks for the explanation, the same is causing issues on my automation i built around the poshacme as well. I think it's not a big issue though to built a full full chain file.

I am just a little confused about the files. I did a renewal today and I got these files

chain.cer and chain0.cer containing the R3 chain1.cer containing the R3 and the ISG Root fullchain.cer containing server cert and R3

So is it expected that the chain1 will always contain the intermediate and the root?

If yes I can then just built a new pfx out of cert.key cert.cer and chain1.cer, but I am unsure what will happen to the chain1.cer sooner or later.

Thanks Alex

rmbolger commented 7 months ago

chain1.cer and chain2.cer (and any additional that might exist) are all of the chains that were offered by the ACME server. chain.cer is the "active" one that the order is currently using and would be identical to one of the numbered ones. The module keeps the others around so it's easy to switch chains without getting a new cert.

In this case, neither 1 or 2 actually contains the root. Again, the ISRG Root X1 you see in 1 is a cross-signed intermediate, not a real root. It's this one: https://letsencrypt.org/certs/isrg-root-x1-cross-signed.pem Not this one: https://letsencrypt.org/certs/isrgrootx1.pem

fullchain.cer is just cert.cer combined with chain.cer.

Assuming nothing changes with their schedule, chain1.cer will also stop getting downloaded on June 6 when LE stops offering that chain option.

If you want to manually build a full chain file that contains the real ISRG Root X1 cert, don't worry about using the PreferredChain option on the order. Just download https://letsencrypt.org/certs/isrgrootx1.pem (either in advance or on demand during the renewal) and create a new file that combines fullchain.cer and isrgrootx1.pem with the root at the end. This should work until LE starts using a new RSA root. Also, stick with RSA private keys because EC keys may eventually chain up to ISRG Root X2 which would break things.

WhileDekker commented 7 months ago

@rmbolger Hey. I did decocde the chain1.cer with openssl and CN said ISG Root X1. If it would have been the cross signed shouldn't the name also show DST...

Anyway, so what you say is that I should not rely on the chain1.cer but rather use the fullchain.cer and the ISG root cer which I download. The thing I still don't get is that you write above that I should build the full chain with the X2 root. Weren't all my certs I got from poshacme so far using th X1? Shouldn't I combine the full chain.cer with the X1.

Thanks Alex

rmbolger commented 7 months ago

So sorry, accidentally linked the X2 pems instead of X1. Ignore X2. You only care about X1.

In any case, the CN on the last cert in chain1 does say ISRG Root X1, but it's the Issuer field that is different than the real root. The Real root has ISRG Root X1 for both CN and Issuer because it is the self-signed root. The cross-signed version has the Issuer as DST Root CA X3 which is what you don't want and is the one in chain1.

But yeah, don't rely on chain1.

WhileDekker commented 7 months ago

Hey, thanks for all your help and your comments. You made my day once again. You are really doing a great job around the poshacme. Really appreciated.

If you are ok I can post my code for the full full chain when it's ready. Just in case someone wants to use it or if you wanna make it as an option to the renewal.

Cheers Alex

WhileDekker commented 7 months ago

Hi,

below is the function i created for the "real full chain" creation. I am calling it after renewal or after inital creation of a new cert within my automation script.

Please use at your own risk!

function Create-additionalcertfiles {

    Param 
    ( 
        [Parameter(Mandatory=$true)] 
        [ValidateNotNullOrEmpty()] 
        [string]$acmepathtocerts,
        [Parameter(Mandatory=$true)] 
        [ValidateNotNullOrEmpty()] 
        [string]$rootcertfile,
        [Parameter(Mandatory=$true)] 
        [ValidateNotNullOrEmpty()] 
        [string]$pfxpass,
        [Parameter(Mandatory=$true)] 
        [ValidateNotNullOrEmpty()] 
        [string]$opensslpath
    )

    $acmepathtocerts = $acmepathtocerts.TrimEnd("\")

    $keyfile = $acmepathtocerts + "\cert.key"
    $certfile = $acmepathtocerts + "\cert.cer"
    $chainfile = $acmepathtocerts + "\chain.cer"

    $fullchainandroot = $acmepathtocerts + "\fullchainandroot.cer"
    $fullchainandrootandkeypem = $acmepathtocerts + "\fullchainandrootandkey.pem"
    $fullchainandrootandkeypfx = $acmepathtocerts + "\fullchainandrootandkey.pfx"

    If (Test-Path $acmepathtocerts){

        Get-Content $certfile,$chainfile,$rootcertfile | Set-Content $fullchainandroot

         If (Test-Path $keyfile){
            Get-Content $keyfile,$certfile,$chainfile,$rootcertfile | Set-Content $fullchainandrootandkeypem

            remove-item $fullchainandrootandkeypfx -ErrorAction SilentlyContinue
            & $opensslpath pkcs12 -export -in $fullchainandrootandkeypem -out $fullchainandrootandkeypfx -password pass:$pfxpass

        }
        Else {
            Write-Host "Key file not available - skipping file creation with key"
        }

    } 
    Else {
        Write-Host "Path $acmepathtocerts invalid"
    }
}
rmbolger commented 6 months ago

If you're looking for constructive criticism, you could simplify and future-proof your file path creations a tiny bit by using Join-Path instead of simple string concatentation. It takes care of dealing with the path separators so you don't need to defensively pre-trim \ or include it in your file paths. For example:

$keyfile = Join-Path $acmepathtocerts 'cert.key'
WhileDekker commented 6 months ago

thx for the hint - I am always looking for code optimization possibilities