Skip to content

Fix Lets Encrypt Issuer Long standing limitations - #1701

Merged
twerthi merged 2 commits into
masterfrom
mh/letsencrypt-issuer-match
Aug 12, 2026
Merged

Fix Lets Encrypt Issuer Long standing limitations#1701
twerthi merged 2 commits into
masterfrom
mh/letsencrypt-issuer-match

Conversation

@harrisonmeister

Copy link
Copy Markdown
Contributor

Background

The Let's Encrypt step templates matched certificates against a hardcoded list of intermediate CA common names, with a second list for staging. Let's Encrypt rotates its intermediates on its own schedule, so the lists go stale and the step stops recognising certificates it created.

The step doesn't fail when that happens. It falls through to the "no existing certificate" path and publishes a duplicate instead of renewing what's already in the store.

Both lists are stale today. YR2 and (STAGING) Ersatz Emmer YR2 are in active use and appear in neither, and E5, E6, E9 and R14 are missing from the production list while their staging counterparts are present.

Results

Two commits, applied to all six templates in the lets-encrypt category.

Match on the issuer organization and the staging prefix. The organization has been Let's Encrypt since 2015, and staging common names carry a documented (STAGING) prefix. Neither changes when an intermediate rotates.

The prefix is what separates the environments, not the organization. Generation Y staging intermediates carry the production organization:

production  CN=YR2,O=Let's Encrypt,C=US
staging     CN=(STAGING) Ersatz Emmer YR2,O=Let's Encrypt,C=US

The step accepts both organization values, because the 2024 generation of staging intermediates uses O=(STAGING) Let's Encrypt. The original Fake LE Intermediate X1 has no organization at all, so the step matches that one on common name. It reads IssuerOrganization and IssuerCommonName from the certificates API, and parses IssuerDistinguishedName when a server doesn't populate them.

Replace the certificate that's expiring. The step tested $expiring_certificates to decide whether to renew, then replaced $certificates | Select-Object -First 1. Those are different sets, so with more than one certificate for a domain the step could renew a healthy certificate and let the expiring one lapse, depending on the order the search returned them in. It now selects from $expiring_certificates, sorted so the closest to expiry wins, and logs which Id it replaced.

No parameters change. Matching only widens, so every certificate found before is still found. This doesn't clean up duplicates already in a store, it stops new ones being created.

Before

A staging run against a domain whose certificate came from (STAGING) Ersatz Emmer YR2 finds nothing, because that name was never added to the staging list:

Checking for existing Lets Encrypt Certificates in the Octopus Deploy Certificates Store.
No existing certificates found for *.thedomain.dev.
Request New Certificate for *.thedomain.dev from Lets Encrypt

A duplicate is published rather than the existing certificate being renewed.

After

The same store, filtered with the new matching. Both certificates share an organization and only the prefix separates them:

skipped   Lets Encrypt - *.thedomain.dev
          issuer CN   YR2
          issuer O    Let's Encrypt
SELECTED  Lets Encrypt - *.thedomain.dev
          issuer CN   (STAGING) Ersatz Emmer YR2
          issuer O    Let's Encrypt

A production run selects the other one. From a live renewal where the domain had two certificates and only one was near expiry:

Replacing certificate Certificates-4, which expires 2026-08-16 09:04:59Z

Tested against a live instance across two domains in both modes (staging vs live). A sample of what changes, and what doesn't:

Issuer common name Issuer organization Step run as Old New
YR2 Let's Encrypt production Renews it Renews it
(STAGING) Ersatz Emmer YR2 Let's Encrypt staging Misses it, publishes a duplicate Renews it
(STAGING) Ersatz Emmer YR2 Let's Encrypt production Ignores it Ignores it
R12 Let's Encrypt production Renews it Renews it
E5 Let's Encrypt production Misses it, publishes a duplicate Renews it
(STAGING) Wannabe Watercress R11 (STAGING) Let's Encrypt staging Renews it Renews it
Fake LE Intermediate X1 none staging Renews it Renews it
ZQ7 (hypothetical future intermediate) Let's Encrypt production Misses it, publishes a duplicate Renews it
DigiCert TLS RSA SHA256 2020 CA1 DigiCert Inc production Ignores it Ignores it

The last row matters as much as the others: widening the match mustn't pull in certificates from other authorities that happen to share a subject. Rows three and nine confirm nothing that was previously ignored is now selected, and no row goes the other way, from renewing to missing.

The script body parses cleanly, and the matching was compared against every common name in the old arrays using Claude code: 480 identical verdicts, with no case where the old code matched and the new code doesn't.

Version bumps:

  • azure-dns 15 to 17
  • cloudflare 13 to 15
  • dnsimple 10 to 12
  • google-cloud 14 to 16
  • route-53 15 to 17
  • selfhosted-http 13 to 15.

No new Category, and no parameters added or changed.

The Let's Encrypt step templates held a hardcoded list of intermediate CA
common names, with a second list for staging. Let's Encrypt rotates its
intermediates on its own schedule. Once it does, any certificate issued by
the new intermediate is invisible to the next run of the step, because the
issuer common name is not in the list.

The step does not fail when that happens. It falls through to the "no
existing certificate" path and publishes a duplicate instead of renewing
the one already in the store.

The lists are already stale. Neither YR2 nor "(STAGING) Ersatz Emmer YR2"
appears in them, and both are in active use. E5, E6, E9 and R14 are
missing from the production list too.

This replaces both lists with two things that do not churn:

  - the issuer organization, which has been "Let's Encrypt" since 2015
  - the "(STAGING) " prefix on the issuer common name, which is what
    separates the environments

The prefix carries the distinction. Generation Y staging intermediates use
the production organization, so the organization alone cannot tell the two
apart:

  production  CN=YR2,O=Let's Encrypt,C=US
  staging     CN=(STAGING) Ersatz Emmer YR2,O=Let's Encrypt,C=US

The step treats both "Let's Encrypt" and "(STAGING) Let's Encrypt" as
Let's Encrypt, because the 2024 generation of staging intermediates uses
the latter. The original "Fake LE Intermediate X1" carries no organization
at all, so the step matches that one on common name instead.

The step reads IssuerOrganization and IssuerCommonName from the
certificates API, and parses IssuerDistinguishedName when a server does
not populate them. The match only widens, so the step still finds every
certificate it found before, and no step parameters change.

All six templates in the lets-encrypt category get the same change.
The step checked $expiring_certificates to decide whether to renew, then
replaced $certificates | Select-Object -First 1. Those are different sets.

A domain can hold more than one certificate in the store, which is easy to
end up with because the previous behaviour published duplicates whenever
the issuer list went stale. When that happens, whether the step renews the
right certificate comes down to the order the search API returns them in.
Get it wrong and the step renews a healthy certificate while the expiring
one lapses.

Selects from $expiring_certificates instead, sorted so the certificate
closest to expiry wins when several are due. Also logs which certificate
Id is being replaced, which matters when a domain has more than one.

Both versions filter to PKCS#12, so a store with no PKCS#12 certificate
fails the same way it did before. The two differ only when an expiring
certificate is PEM and a non-expiring PKCS#12 exists alongside it, where
the old code renewed the healthy one. Failing there is the better outcome.

All six templates in the lets-encrypt category get the same change.
@github-actions

Copy link
Copy Markdown

Review this PR in Hyponome for a side-by-side diff of the step-template JSON and any embedded scripts.

@harrisonmeister

Copy link
Copy Markdown
Contributor Author

@benjimac93 - tagging you in case you wanted to take a look (I think you have used these in the past). Feedback welcome 👍

@twerthi twerthi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@twerthi
twerthi merged commit 5cc7af5 into master Aug 12, 2026
4 checks passed
@twerthi
twerthi deleted the mh/letsencrypt-issuer-match branch August 12, 2026 17:14
@benjimac93

Copy link
Copy Markdown
Contributor

@harrisonmeister this seems a LOT more sensible - have pulled this into our instance this morning, and is working as expected :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants